Merge branch 'stable-3.3' into stable-3.4

* stable-3.3:
  Bump bazel version to 4.2.2
  Update git submodules
  Set PerThreadCache as readonly after creating a new patch-set
  Set PerThreadCache as readonly when formatting change e-mails
  Set PerThreadCache as readonly when formatting change JSON
  Set PerThreadCache as readonly after deleting a change
  Set PerThreadCache as readonly after abandoning a change
  Set PerThreadCache as readonly after merging a change
  Set PerThreadCache as readonly after posting review comments
  Introduce unloaders on PerThreadCache entries
  RepoRefCache: Hold a reference to the refDatabase with ref counting
  Remove use of RefCache in ChangeNotes
  Revert "Cache change /meta ref SHA1 for each REST API request"
  Cache change /meta ref SHA1 for each change indexing task

Release-Notes: skip
Change-Id: Ie357535a6992a2256252e3b0e77df24bf090a674
diff --git a/.bazelrc b/.bazelrc
index 72138d2..4117994 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -11,6 +11,8 @@
 # this flag here once flipped in Bazel again.
 build --incompatible_strict_action_env
 
+build --announce_rc
+
 test --build_tests_only
 test --test_output=errors
 test --java_toolchain=//tools:error_prone_warnings_toolchain
diff --git a/BUILD b/BUILD
index c48b3b9..084d383 100644
--- a/BUILD
+++ b/BUILD
@@ -56,19 +56,22 @@
 API_DEPS = [
     "//java/com/google/gerrit/acceptance:framework_deploy.jar",
     "//java/com/google/gerrit/acceptance:libframework-lib-src.jar",
-    "//java/com/google/gerrit/acceptance:framework-javadoc",
     "//java/com/google/gerrit/extensions:extension-api_deploy.jar",
     "//java/com/google/gerrit/extensions:libapi-src.jar",
-    "//java/com/google/gerrit/extensions:extension-api-javadoc",
     "//plugins:plugin-api_deploy.jar",
     "//plugins:plugin-api-sources_deploy.jar",
+]
+
+API_JAVADOC_DEPS = [
+    "//java/com/google/gerrit/acceptance:framework-javadoc",
+    "//java/com/google/gerrit/extensions:extension-api-javadoc",
     "//plugins:plugin-api-javadoc",
 ]
 
 genrule2(
     name = "api",
     testonly = True,
-    srcs = API_DEPS,
+    srcs = API_DEPS + API_JAVADOC_DEPS,
     outs = ["api.zip"],
     cmd = " && ".join([
         "cp $(SRCS) $$TMP",
@@ -76,3 +79,15 @@
         "zip -qr $$ROOT/$@ .",
     ]),
 )
+
+genrule2(
+    name = "api-skip-javadoc",
+    testonly = True,
+    srcs = API_DEPS,
+    outs = ["api-skip-javadoc.zip"],
+    cmd = " && ".join([
+        "cp $(SRCS) $$TMP",
+        "cd $$TMP",
+        "zip -qr $$ROOT/$@ .",
+    ]),
+)
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index e2d3c6a..2a019ca 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -93,8 +93,8 @@
 == Predefined Groups
 
 Predefined groups differs from system groups by the fact that they
-exist in the ACCOUNT_GROUPS table (like normal groups) but predefined groups
-are created on Gerrit site initialization and unique UUIDs are assigned
+exist in NoteDb under refs/meta/group-names (like normal groups) but predefined
+groups are created on Gerrit site initialization and unique UUIDs are assigned
 to those groups. These UUIDs are different on different Gerrit sites.
 
 Gerrit comes with two predefined groups:
diff --git a/Documentation/cmd-convert-ref-storage.txt b/Documentation/cmd-convert-ref-storage.txt
new file mode 100644
index 0000000..aae385f
--- /dev/null
+++ b/Documentation/cmd-convert-ref-storage.txt
@@ -0,0 +1,58 @@
+= gerrit convert-ref-storage
+
+== NAME
+gerrit convert-ref-storage - Convert ref storage to reftable (experimental).
+
+A reftable file is a portable binary file format customized for reference storage.
+References are sorted, enabling linear scans, binary search lookup, and range scans.
+
+See also link:https://www.git-scm.com/docs/reftable for more details[reftable,role=external,window=_blank]
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit convert-ref-storage_
+  [--format <format>]
+  [--backup | -b]
+  [--reflogs | -r]
+  [--project <PROJECT> | -p <PROJECT>]
+--
+
+== DESCRIPTION
+Convert ref storage to reftable.
+
+== ACCESS
+Administrators
+
+== OPTIONS
+--project::
+-p::
+	Required; Name of the project for which the ref format should be changed.
+
+--format::
+	Format to convert to: `reftable` or `refdir`.
+	Default: reftable.
+
+--backup::
+-b::
+	Create backup of old ref storage format.
+	Default: true.
+
+--reflogs::
+-r::
+	Write reflogs to reftable.
+	Default: true.
+
+== EXAMPLES
+
+Convert ref format for project "core" to reftable:
+----
+$ ssh -p 29418 review.example.com gerrit convert-ref-format -p core
+----
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/cmd-create-project.txt b/Documentation/cmd-create-project.txt
index 9e3d70b..0575eb9 100644
--- a/Documentation/cmd-create-project.txt
+++ b/Documentation/cmd-create-project.txt
@@ -53,9 +53,10 @@
 -b::
 	Name of the initial branch(es) in the newly created project.
 	Several branches can be specified on the command line.
-	If several branches are specified then the first one becomes HEAD
-	of the project. If none branches are specified then default value
-	('master') is used.
+	If several branches are specified then the first one becomes
+	link:project-configuration.html#default-branch[HEAD] of the project.
+	If none branches are specified then link:config-gerrit.html#gerrit.defaultBranch[host-level default]
+	is used.
 
 --owner::
 -o::
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index 8a970c5..99ff0db 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -118,6 +118,9 @@
 link:cmd-close-connection.html[gerrit close-connection]::
 	Close the specified SSH connection.
 
+link:cmd-convert-ref-storage.html[gerrit convert-ref-storage]::
+	Convert ref storage to reftable (experimental).
+
 link:cmd-create-account.html[gerrit create-account]::
 	Create a new user account.
 
diff --git a/Documentation/concept-changes.txt b/Documentation/concept-changes.txt
index 6de787c..0444fab 100644
--- a/Documentation/concept-changes.txt
+++ b/Documentation/concept-changes.txt
@@ -104,6 +104,9 @@
 Assigning a topic to a change can be done in the change screen or through a `git
 push` command.
 
+For more information about using topics, see the user guide:
+link:cross-repository-changes.html[Submitting Changes Across Repositories by using Topics].
+
 [[submit-strategies]]
 == Submit strategies
 
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
index 67159f4..9b99960 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -293,12 +293,12 @@
 External IDs are stored as Git Notes in the `All-Users` repository. The
 name of the notes branch is `refs/meta/external-ids`.
 
-As note key the SHA1 of the external ID key is used, for example the key
+As note key the SHA-1 of the external ID key is used, for example the key
 for the external ID `username:jdoe` is `e0b751ae90ef039f320e097d7d212f490e933706`.
 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).
 
-The following commands show how to find the SHA1 of an external ID:
+The following commands show how to find the SHA-1 of an external ID:
 
 ----
 $ echo -n 'gerrit:jdoe' | shasum
@@ -310,7 +310,7 @@
 
 [IMPORTANT]
 If the external ID key is changed manually you must adapt the note key
-to the new SHA1, otherwise the external ID becomes inconsistent and is
+to the new SHA-1, otherwise the external ID becomes inconsistent and is
 ignored by Gerrit.
 
 The note content is a Git config file:
@@ -322,7 +322,7 @@
   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
 ----
 
-Once SHA1 of an external ID is known the following command can be used to
+Once SHA-1 of an external ID is known the following command can be used to
 show the content of the note:
 
 ----
@@ -343,6 +343,12 @@
 The `accountId` field is mandatory. The `email` and `password` fields
 are optional.
 
+Note that git will automatically nest these notes at varying levels. If
+refs/meta/external-ids:7c/2a55657d911109dbc930836e7a770fb946e8ef is not
+found then check
+refs/meta/external-ids:7c/2a/55657d911109dbc930836e7a770fb946e8ef and
+so on.
+
 The external IDs are maintained by Gerrit. This means users are not
 allowed to manually edit their external IDs. Only users with the
 link:access-control.html#capability_accessDatabase[Access Database]
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 085a5d5..f088240 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -504,10 +504,10 @@
 +
 When `auth.type` does not normally enable this URL administrators may
 set this to `login/`, allowing users to begin a new web session. This value
-is used as an href in PolyGerrit, so absolute URLs like
+is used as an href in the Gerrit web app, so absolute URLs like
 `https://someotherhost/login` work as well.
 +
-If a ${path} parameter is included, then PolyGerrit will substitute the
+If a ${path} parameter is included, then the Gerrit web app will substitute the
 currently viewed path in the link. Be aware that this path will include
 a leading slash, so a value like this might be appropriate: `/login${path}`.
 
@@ -826,6 +826,7 @@
 * `"groups"`: default is unlimited
 * `"groups_byname"`: default is unlimited
 * `"groups_byuuid"`: default is unlimited
+* `"groups_byuuid_persisted"`: default is `1g` (1 GiB of disk space)
 * `"plugin_resources"`: default is 2m (2 MiB of memory)
 
 +
@@ -1005,6 +1006,11 @@
 be expensive to compute (60 or more seconds for a large history
 like the Linux kernel repository).
 
+cache `"comment_context"`::
++
+Caches the context lines of comments, which are the lines of the source file
+highlighted by the user when the comment was written.
+
 cache `"groups"`::
 +
 Caches the basic group information of internal groups by group ID,
@@ -1044,6 +1050,17 @@
 External group membership obtained from LDAP is cached under
 `"ldap_groups"`.
 
+cache `"groups_byuuid_persisted"`::
++
+Caches the basic group information of internal groups by group UUID,
+including the group owner, name, and description.
++
+This is the persisted version of `groups_byuuid` cache. The intention of this
+cache is to have an in-memory size of 0.
++
+External group membership obtained from LDAP is cached under
+`"ldap_groups"`.
+
 cache `"groups_bymember"`::
 +
 Caches the groups which contain a specific member (account). If direct
@@ -1325,17 +1342,6 @@
 +
 The default is false.
 
-[[change.largeChange]]change.largeChange::
-+
-Number of changed lines from which on a change is considered as a large
-change. The number of changed lines of a change is the sum of the lines
-that were inserted and deleted in the change.
-+
-The specified value is used to visualize the change sizes in the Web UI
-in change tables and user dashboards.
-+
-By default 500.
-
 [[change.maxComments]]change.maxComments::
 +
 Maximum number of comments (regular plus robot) allowed per change. Additional
@@ -1343,6 +1349,20 @@
 +
 By default 5,000.
 
+[[change.maxFiles]]change.maxFiles::
++
+Maximum number of files allowed per change. Larger changes are rejected and must
+be split up.
++
+By default 100,000.
+
+[[change.maxPatchSets]]change.maxPatchSets::
++
+Maximum number of patch sets allowed per change. If this is insufficient,
+recreate the change with a new Change-Id, then abandon the old change.
++
+By default 1,500.
+
 [[change.maxUpdates]]change.maxUpdates::
 +
 Maximum number of updates to a change. Counts only updates to the main NoteDb
@@ -1389,7 +1409,7 @@
   query operator. Gerrit does not serve `mergeable` in
   link:rest-api-changes.html#change-info[ChangeInfo].
 
-Default is `REF_UPDATED_AND_CHANGE_REINDEX`.
+Default is `NEVER`.
 
 [[change.move]]change.move::
 +
@@ -1732,6 +1752,12 @@
 ----
   javaOptions = -Dlog4j.configuration=file:///home/gerrit/site/etc/log4j.properties
 ----
++
+Gerrit built-in loggers are then ignored: error logger (`error_log` file),
+link:#httpd.requestLog[httpd.requestLog] and
+link:#sshd.requestLog[sshd.requestLog]. The
+link:#log.jsonLogging[log.jsonLogging] and
+link:#log.textLogging[log.textLogging] options are also ignored.
 
 [[container.daemonOpt]]container.daemonOpt::
 +
@@ -2119,6 +2145,13 @@
 +
 Defaults to `All-Projects` if not set.
 
+[[gerrit.defaultBranch]]gerrit.defaultBranch::
++
+Name of the link:project-configuration.html#default-branch[default branch]
+to use on the project creation, if no other branches were specified in the input.
++
+Defaults to `refs/heads/master` if not set.
+
 [[gerrit.allUsers]]gerrit.allUsers::
 +
 Name of the project in which meta data of all users is stored.
@@ -2274,16 +2307,11 @@
 By default unset, meaning no bug report URL will be displayed. Administrators
 should set this to the URL of their issue tracker, if necessary.
 
-[[gerrit.enableReverseDnsLookup]]gerrit.enableReverseDnsLookup::
-+
-Enable reverse DNS lookup during computing ref log entry for identified user,
-to record the actual hostname of the user's host in the ref log.
-+
-Enabling reverse DNS lookup can cause performance issues on git push when
-the reverse DNS lookup is slow.
-+
-Defaults to false, reverse DNS lookup is disabled. The user's IP address
-will be recorded in the ref log rather than their hostname.
+[[gerrit.enablePeerIPInReflogRecord]]gerrit.enablePeerIPInReflogRecord::
+
+Record actual peer IP address in ref log entry for identified user.
+
+Defaults to false.
 
 [[gerrit.secureStoreClass]]gerrit.secureStoreClass::
 +
@@ -2323,11 +2351,11 @@
 
 [[gerrit.cdnPath]]gerrit.cdnPath::
 +
-Path prefix for PolyGerrit's static resources if using a CDN.
+Path prefix for Gerrit's static resources if using a CDN.
 
 [[gerrit.faviconPath]]gerrit.faviconPath::
 +
-Path for PolyGerrit's favicon after link:#gerrit.canonicalWebUrl[default URL],
+Path for Gerrit's favicon after link:#gerrit.canonicalWebUrl[default URL],
 including icon name and extension (.ico should be used).
 
 [[gerrit.instanceId]]gerrit.instanceId::
@@ -2429,7 +2457,7 @@
 at a specific commit when `gitweb.type` is set to `custom`.
 +
 Valid replacements are `${project}` for the project name in Gerrit
-and `${commit}` for the SHA1 hash for the commit.
+and `${commit}` for the SHA-1 hash for the commit.
 
 [[gitweb.project]]gitweb.project::
 +
@@ -2461,7 +2489,7 @@
 is set to `custom`.
 +
 Valid replacements are `${project}` for the project name in Gerrit
-and `${commit}` for the SHA1 hash for the commit.
+and `${commit}` for the SHA-1 hash for the commit.
 
 [[gitweb.file]]gitweb.file::
 +
@@ -2470,8 +2498,9 @@
 set to `custom`.
 +
 Valid replacements are `${project}` for the project name in Gerrit,
-`${file}` for the file name and `${commit}` for the SHA1 hash for
-the commit.
+`${file}` for the file name, `${hash}` for the SHA-1 hash for the commit,
+and `${commit}` for the change ref or SHA-1 of the commit if no base
+patch set.
 
 [[gitweb.filehistory]]gitweb.filehistory::
 +
@@ -2523,6 +2552,18 @@
 [[groups]]
 === Section groups
 
+[[groups.includeExternalUsersInRegisteredUsersGroup]]groups.includeExternalUsersInRegisteredUsersGroup::
++
+Controls whether external users (these are users we have sufficient
+knowledge about but who don't yet have a Gerrit account) are considered
+to be members of the `REGISTERED_USERS` group.
++
+This setting only makes sense if you run custom code (e.g. from a plugin
+or a custom authentication backend). By default, Gerrit core always requires
+users to register and doesn't use external users.
++
+By default, true.
+
 [[groups.newGroupsVisibleToAll]]groups.newGroupsVisibleToAll::
 +
 Controls whether newly created groups should be by default visible to
@@ -3339,6 +3380,19 @@
 +
 Defaults to 10000.
 
+[[elasticsearch.connectTimeout]]elasticsearch.connectTimeout::
++
+Sets the timeout for connecting to elasticsearch.
++
+Defaults to `1 second`.
+
+[[elasticsearch.socketTimeout]]elasticsearch.socketTimeout::
++
+Sets the timeout for the underlying connection. For more information, refer to
+link:#httpd.idleTimeout[`httpd.idleTimeout`].
++
+Defaults to `30 seconds`.
+
 ==== Elasticsearch Security
 
 When security is enabled in Elasticsearch, the username and password must be provided.
@@ -3400,9 +3454,9 @@
 [[experiments]]
 === Section experiments
 
-This section covers experimental new features. Gerrit's frontend uses experiments
-to research new behavior. Once the research is done, the experimental feature
-either stays and the experimentation flag gets removed, or the feature as a whole
+This section covers experimental new features. Gerrit uses experiments
+to research new behavior in frontend and core backend. Once the research is done, the experimental
+feature either stays and the experimentation flag gets removed, or the feature as a whole
 gets removed
 
 [[experiments.enabled]]experiments.enabled::
@@ -3850,8 +3904,13 @@
 
 [[log.jsonLogging]]log.jsonLogging::
 +
-If set to true, enables error, ssh and http logging in JSON format (file name:
-"logs/{error|sshd|httpd}_log.json").
+If set to true, enables error, ssh and http logging in JSON format (file names:
+`logs/error_log.json`, `logs/sshd_log.json` and `logs/httpd_log.json`).
++
+The option only applies to Gerrit built-in loggers. It is ignored when a log4j
+configuration is specified via
+link:#container.javaOptions[container.javaOptions], for example
+`-Dlog4j.configuration=file://etc/log4j.properties`.
 +
 Defaults to false.
 
@@ -3860,6 +3919,11 @@
 If set to true, enables error logging in regular plain text format. Can only be disabled
 if `jsonLogging` is enabled.
 +
+The option only applies to Gerrit built-in loggers. It is ignored when a log4j
+configuration is specified via
+link:#container.javaOptions[container.javaOptions], for example
+`-Dlog4j.configuration=file://etc/log4j.properties`.
++
 Defaults to true.
 
 [[log.compress]]log.compress::
@@ -4132,7 +4196,7 @@
 very CPU-heavy operation. For non public Gerrit-servers this check may
 be overkill.
 +
-Only disable this check if you trust the clients not to forge SHA1
+Only disable this check if you trust the clients not to forge SHA-1
 references to access commits intended to be hidden from the user.
 +
 Default is true.
@@ -4772,6 +4836,16 @@
   replicate = replication start
 ----
 
+[[ssh]]
+=== Section ssh
+
+[[ssh.clientImplementation]]ssh.clientImplementation::
++
+JCraft JSch client is supported in addition to Apache MINA SSH client.
+To use JSch client set the value to `JSCH`.
++
+By default, `APACHE`.
+
 [[sshd]]
 === Section sshd
 
@@ -5028,6 +5102,16 @@
 +
 By default, all supported MACs are available.
 
+[[sshd.enableDeprecatedKexAlgorithms]]sshd.enableDeprecatedKexAlgorithms::
++
+Enable deprecated kex algorithms:
++
+* `diffie-hellman-group1-sha1`
+* `diffie-hellman-group14-sha1`
+* `diffie-hellman-group-exchange-sha1`
+
+By default, the deprecated kex algorithms are disabled.
+
 [[sshd.kex]]sshd.kex::
 +
 --
@@ -5038,24 +5122,20 @@
 algorithms, key exchange algorithm names starting with `-` are
 removed from the default key exchange algorithms.
 
-In the following example configuration, support for the 1024-bit
-`diffie-hellman-group1-sha1` key exchange is disabled while leaving
-all of the other default algorithms enabled:
-
-----
-[sshd]
-  kex = -diffie-hellman-group1-sha1
-----
-
 Supported key exchange algorithms:
 
 * `ecdh-sha2-nistp521`
 * `ecdh-sha2-nistp384`
 * `ecdh-sha2-nistp256`
 * `diffie-hellman-group-exchange-sha256`
-* `diffie-hellman-group-exchange-sha1`
-* `diffie-hellman-group14-sha1`
-* `diffie-hellman-group1-sha1`
+* `diffie-hellman-group18-sha512`
+* `diffie-hellman-group17-sha512`
+* `diffie-hellman-group16-sha512`
+* `diffie-hellman-group15-sha512`
+* `diffie-hellman-group14-sha256`
+
+See link:#sshd.enableDeprecatedKexAlgorithms[sshd.enableDeprecatedKexAlgorithms]
+for deprecated key algorithms and how to enable them.
 
 By default, all supported key exchange algorithms are available.
 
@@ -5645,10 +5725,10 @@
 [[protocol.version]]protocol.version::
 +
 If set, the server will accept requests from a client attempting to communicate
-using the specified protocol version. Otherwise communication falls back to version 0.
-If set in file `etc/jgit.config` this option will be used for all repositories of
-the site. It can be overridden for a given repository by configuring a different
-value in the repository's `config` file.
+using the specified protocol version. Default is `2`. If set in file
+`etc/jgit.config` this option will be used for all repositories of the site.
+It can be overridden for a given repository by configuring a different value in
+the repository's `config` file.
 +
 Supported versions:
 0:: the original wire protocol.
diff --git a/Documentation/config-gitweb.txt b/Documentation/config-gitweb.txt
index 46c9ced..00e33a3 100644
--- a/Documentation/config-gitweb.txt
+++ b/Documentation/config-gitweb.txt
@@ -268,7 +268,22 @@
 
 cgit can be used by specifying `gitweb.type` to be 'cgit'.
 
-It is also possible to define custom patterns.
+It is also possible to define custom patterns. Gitea can be used
+with custom patterns for example:
+
+----
+  git config -f $site_path/etc/gerrit.config gitweb.type custom
+  git config -f $site_path/etc/gerrit.config gitweb.urlEncode false
+  git config -f $site_path/etc/gerrit.config gitweb.linkname gitea
+  git config -f $site_path/etc/gerrit.config gitweb.url https://gitea.example.org/
+  git config -f $site_path/etc/gerrit.config gitweb.branch ${project}/src/branch/${branch}
+  git config -f $site_path/etc/gerrit.config gitweb.file ${project}/src/commit/${hash}/${file}
+  git config -f $site_path/etc/gerrit.config gitweb.filehistory ${project}/commits/branch/${branch}/${file}
+  git config -f $site_path/etc/gerrit.config gitweb.project ${project}
+  git config -f $site_path/etc/gerrit.config gitweb.revision ${project}/commit/${commit}
+  git config -f $site_path/etc/gerrit.config gitweb.roottree ${project}/src/commit/${commit}
+  git config -f $site_path/etc/gerrit.config gitweb.tag ${project}/src/tag/${tag}
+----
 
 === SEE ALSO
 
diff --git a/Documentation/config-groups.txt b/Documentation/config-groups.txt
index afabbfc..0917515 100644
--- a/Documentation/config-groups.txt
+++ b/Documentation/config-groups.txt
@@ -64,7 +64,7 @@
 
 The format of this map is as follows:
 
-* keys are the normal SHA1 of the group name
+* keys are the normal SHA-1 of the group name
 * values are blobs that look like
 +
 ----
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index a3b9d0b..9d3446e 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -283,6 +283,22 @@
 sticky approvals, reducing turn-around for trivial cleanups prior to
 submitting a change. Defaults to false.
 
+[[label_copyAllScoresIfListOfFilesDidNotChange]]
+=== `label.Label-Name.copyAllScoresIfListOfFilesDidNotChange`
+
+This policy is useful if you don't want to trigger CI or human
+verification again if the list of files didn't change.
+
+If true, all scores for the label are copied forward when a new
+patch-set is uploaded that has the same list of files as the previous
+patch-set.
+
+Renames are considered different files when computing whether new files
+were added or old files were deleted. Hence, if there are renames, scores will
+*NOT* be copied over.
+
+Defaults to false.
+
 [[label_copyAllScoresOnMergeFirstParentUpdate]]
 === `label.Label-Name.copyAllScoresOnMergeFirstParentUpdate`
 
@@ -337,7 +353,7 @@
 If true, all scores for the label are copied forward when a new patch
 set is uploaded that has the same parent tree, code delta, and commit
 message as the previous patch set. This means that only the patch
-set SHA1 is different. This can be used to enable sticky
+set SHA-1 is different. This can be used to enable sticky
 approvals, reducing turn-around for this special case.
 It is recommended to leave this enabled for both Verified and
 Code-Review labels.
diff --git a/Documentation/config-plugins.txt b/Documentation/config-plugins.txt
index 272c4eb..3433c15 100644
--- a/Documentation/config-plugins.txt
+++ b/Documentation/config-plugins.txt
@@ -49,7 +49,7 @@
 [[codemirror-editor]]
 === codemirror-editor
 
-CodeMirror plugin for polygerrit.
+CodeMirror JavaScript plugin for Gerrit.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/codemirror-editor[
 Project,role=external,window=_blank] |
diff --git a/Documentation/config-validation.txt b/Documentation/config-validation.txt
index cb953c1..56c9ecd 100644
--- a/Documentation/config-validation.txt
+++ b/Documentation/config-validation.txt
@@ -21,6 +21,14 @@
 Out of the box, Gerrit includes a plugin that checks the length of the
 subject and body lines of commit messages on uploaded commits.
 
+[plugin-push-options]]
+=== Plugin push options
+
+Plugins can register push options by implementing the `PluginPushOption`
+interface. If a plugin push option was specified it is available from
+the `CommitReceivedEvent` that is passed into `CommitValidationListener`.
+This way the plugin commit validation can be controlled by push options.
+
 [[user-ref-operations-validation]]
 == User ref operations validation
 
diff --git a/Documentation/cross-repository-changes.txt b/Documentation/cross-repository-changes.txt
new file mode 100644
index 0000000..53fd5cd
--- /dev/null
+++ b/Documentation/cross-repository-changes.txt
@@ -0,0 +1,254 @@
+:linkattrs:
+= Gerrit Code Review - Submitting Changes Across Repositories by using Topics
+
+== Goal
+
+This document describes how to propose and submit code changes across multiple
+Git repositories together in Gerrit.
+
+== When to Use
+
+Oftentimes, especially for larger code bases, code is split across multiple
+repositories. The Android operating system’s code base, for example, consists of
+https://android.googlesource.com/[hundreds] of separate repositories. When
+making a change, you might make code changes that span multiple repositories.
+For example, one repository could define an API which is used in another
+repository. Submitting these changes across these repositories separately could
+cause the build to break for other developers.
+
+Gerrit provides a mechanism called link:intro-user.html#topics[Topics] to submit
+changes together to prevent this problem.
+
+|===
+|NOTE: Usage of topics to submit multiple changes together requires your
+Gerrit host having
+link:config-gerrit.html#change.submitWholeTopic[config.submitWholeTopic] set to
+true. Ask your Gerrit administrator if you're not sure if this is enabled for
+your Gerrit instance.
+|===
+
+== What is a Topic?
+
+* A topic is a string that can be associated with a change.
+* Multiple changes can use that topic to be submitted at the same time (assuming
+  approvals, etc.).
+* Submitting a change with a topic causes all of the changes in the topic *to be
+  submitted together*
+  ** Topics that span only a single repository are guaranteed to be submitted
+  together
+  ** Topics that span multiple repositories simply triggers submission of all
+  changes. No other guarantees are given. Submission of all changes could
+  fail, so you could get a partial topic submission. This is very rare but
+  can happen in some of the following situations:
+  *** Storage layer failures. This is unlikely in single-master installation and
+  more likely with multi-master setups.
+  *** Race conditions. Concurrent submits to the same repository or concurrent
+  updates of the pending changes.
+
+Here are a few intricacies you should be aware of:
+
+1. Topics can only be used for changes within a single Gerrit instance. There is
+no builtin support for synchronizing with other Gerrit or Git hosting sites.
+
+2. A topic can be any string, and they are not namespaced in a Gerrit instance;
+there is a chance for collisions and inadvertently grouping changes together
+that weren’t meant to be grouped. This could even happen with changes you can’t
+see, leading to more confusion e.g. (change not submittable, but you can't see
+why it's not submittable.). We suggest prefixing topic strings with the author’s
+username e.g. “username-” to help avoid this.
+
+You can view the assigned topic from the change screen in Gerrit:
+
+image::images/cross-repository-changes-topic.png[width=600]
+
+=== Topic submission behavior
+* Submitting a topic will submit any dependent changes as well. For example,
+  an unsubmitted parent change will also be submitted, even if it isn’t in the
+  original topic.
+* A change with a topic is submittable when *all changes* in the topic are
+  submittable and *all of the changes’ dependent changes* (and their topics!)
+  are also submittable.
+* Gerrit calls the totality of these changes "Submitted Together", and they can
+be found with the
+  link:rest-api-changes.html#submitted-together[Submitted Together endpoint] or
+  on the change screen.
+
+image::images/cross-repository-changes-submitted-together.png[width=600]
+
+* A submission creates a unique submission ID
+    (link:rest-api-changes.html#change-info[`submission_id`]), which can be
+    used in Gerrit's search bar to find all the submitted changes for the
+    submission. This ID is relevant when <<reverting,reverting a submission>>.
+
+To better underestand this behavior, consider this following example.
+
+[[example_submission]]
+=== Example Submission
+
+image::images/cross-repository-changes-example.png[width=600]
+
+* Two repositories: A and B
+* Two changes in A: A1 and A2, where A2 is the child change.
+* Two changes in B: B1 and B2, where B2 is the child change.
+* Topic X contains change A1 and B1
+* Topic Y contains change A2 and B2
+
+Submission of A2 will submit all four of these changes because submission of A2
+submits all of topic Y as well as all dependent changes and their topics i.e. A1
+and topic X.
+
+Because of this, any submission is blocked until all four of these changes are
+submittable.
+
+|===
+| Important point: B1 can unexpectedly block the submission of A2!
+This kind of situation is hard to immediately grok: B1 isn't in the topic you're
+trying to submit, and it isn't a depnedent change of A2. If your topic isn’t
+submittable and you can’t figure out why, this might be a reason.
+|===
+
+== Submitting Changes Using Topics
+
+=== 1. *Associate the changes to a topic*
+
+The first step is to associate all the changes you want to be submitted together
+with the same topic. There are multiple ways to associate changes with a topic.
+
+==== From the command line
+You can set the topic name when uploading to Gerrit
+
+----
+$ git push origin HEAD:refs/heads/master -o topic=[YOUR_TOPIC_NAME]
+----
+
+*OR*
+
+----
+$ git push origin HEAD:refs/for/master%topic=[YOUR_TOPIC_NAME]
+----
+
+If you’re using https://source.android.com/setup/develop[repo] to upload a
+change to Android Gerrit, you can associate a topic via:
+
+----
+$ repo upload -o topic=[YOUR_TOPIC_NAME]
+----
+
+If you’re using
+https://commondatastorage.googleapis.com/chrome-infra-docs/flat/depot_tools/docs/html/depot_tools.html[depot_tools]
+to upload a change to Chromium Gerrit, you can associate a topic via:
+
+----
+$ git cl upload --topic=[YOUR_TOPIC_NAME]
+----
+
+==== From the UI
+
+If the change has already been created, you can add a topic from the change page
+by clicking ADD TOPIC, found on the left side of the top of the Change screen.
+
+image::images/cross-repository-changes-add-topic.png[width=600]
+
+=== 2. *Go through the normal code review process*
+
+Each change still goes through the normal code review process where reviewers
+vote on each change individually. The changes won’t be able to be submitted
+until *all* changes in the topic are submittable.
+
+The requirements for submittability vary based on rules set by your repository
+administrators; often this includes being approved by all requisite parties,
+passing presubmit testing, and being able to merge cleanly (without conflicts)
+into the target branch.
+
+=== 3. *Submit the change*
+
+When all changes in the topic are submittable, you’ll see *SUBMIT WHOLE TOPIC*
+at the top of the _Change screen_. Clicking it will submit all the changes in
+"Submitted Together."
+
+image::images/cross-repository-changes-submit-topic.png[width=600]
+
+[[reverting]]
+== Reverting a Submission
+
+After a topic is submitted, you can revert all or one of the changes by clicking
+the *REVERT* button on any change.
+
+image::images/cross-repository-changes-revert-topic.png[width=600]
+
+This will give you the option to either revert just the change in question or
+the entire topic:
+
+image::images/cross-repository-changes-revert-topic-options.png[width=600]
+
+Reverting the entire submission creates revert commits for each change and
+automatically associates them together under the same topic. To submit
+these changes, go through the normal review process.
+
+When submitting a topic, dependent changes and their topics are submitted as
+well. The RevertSubmission creates reverts for all the changes that were
+submitted at that time. When reverting the submission described in
+<<example_submission,Example Submission>>, all 4 of those changes will get
+reverted.
+
+|===
+| NOTE: We say “reverting a submission” instead of “reverting a submitted
+  topic” because submissions are defined by submission id, not by the topic
+  string. So even though topics names could be reused, this doesn't effect
+  reverting. For example:
+
+  1. Submission #1 uses topic A
+
+  2. Later, Submission #2 uses topic A again
+
+  Reverting submission #2 only reverts the changes in that submission, not all
+  changes included in topic A.
+|===
+
+== Cherry-Picking a Topic
+
+You may want to cherry-pick the changes (i.e. copy the changes) of a topic to
+another branch, perhaps because you have multiple branches that all need to be
+updated with the same change (e.g. you're porting a security fix across
+branches). Gerrit provides a mechanism to create these changes.
+
+From the overflow menu (3 dot icon) in the top right of the Change Screen,
+select “Cherry pick.” In the screenshot below, we’re showing this on a
+submitted change, but this option is available if the change is pending as
+well.
+
+image::images/cross-repository-changes-cp-menu.png[width=600]
+
+Afterwards, you’ll be presented with a modal where you can “Cherry Pick entire
+topic.”
+
+image::images/cross-repository-changes-cp-modal.png[width=600]
+
+Enter the branch name that you want to target for these repositories. The
+branch must already exist on all of the repositories. After clicking
+“CHERRY PICK,” Gerrit will create new changes all targeting the entered
+branch in their respective repositories, and these new changes will all be
+associated with a new, uniquely-generated topic name.
+
+To submit the cherry-picked changes, go through the normal submission
+process.
+
+|===
+| NOTE: You cannot cherry pick two or more changes that all target the same
+ repository from the Gerrit UI at this time; you’ll get an error message saying
+ “changes cannot be of the same repository.” To accomplish this, you’d
+ need to do the cherry-pick locally.
+|===
+
+== Searching for Topics
+
+In the Gerrit search bar, you can search for changes attached to a specific
+topic using the `topic` operator e.g. `topic:MY_TOPIC_NAME`. The `intopic`
+operator works similary but supports free-text and regular expression search.
+
+You can also search for a submission using the `submissionid` operator. Topic
+submission IDs are "<id>-<topic>" where id is the change number of the change
+that triggered the submission (though this could change in the future). As a
+full example, if the topic name is my-topic and change 12345 was the one that
+triggered submission, you could find it with `submissionid:12345-my-topic`.
+
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 5d71b41..c05d3f4 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -137,7 +137,7 @@
 [[release]]
 === Gerrit Release WAR File
 
-To build the Gerrit web application that includes the PolyGerrit UI,
+To build the Gerrit web application that includes the Gerrit UI,
 core plugins and documentation:
 
 ----
@@ -152,8 +152,7 @@
 
 === Headless Mode
 
-To build Gerrit in headless mode, i.e. without the PolyGerrit UI:
-Web UI:
+To build Gerrit in headless mode, i.e. without the Gerrit UI:
 
 ----
   bazel build headless
@@ -288,6 +287,18 @@
   bazel-bin/withdocs.war
 ----
 
+Alternatively, one can generate the documentation as flat files:
+
+----
+  bazel build Documentation:Documentation
+----
+
+The html, css, js files are placed in:
+
+----
+ `bazel-bin/Documentation/`
+----
+
 [[tests]]
 == Running Unit Tests
 
@@ -313,6 +324,18 @@
   bazel test //javatests/com/google/gerrit/acceptance/rest/account:rest_account
 ----
 
+To run SSH tests using JSch ssh client:
+
+----
+  bazel test --test_env=SSH_CLIENT_IMPLEMENTATION=JSCH //...
+----
+
+To run SSH tests using Apache MINA ssh client:
+
+----
+  bazel test --test_env=SSH_CLIENT_IMPLEMENTATION=APACHE //...
+----
+
 To run only tests that do not use SSH:
 
 ----
@@ -365,6 +388,29 @@
 * server
 * ssh
 
+Bazel itself supports a multitude of ways to
+link:https://docs.bazel.build/versions/master/guide.html#specifying-targets-to-build[specify targets,role=external,window=_blank]
+for fine-grained test selection that can be combined with many of the examples
+above.
+
+[[debugging-tests]]
+== Debugging Unit Tests
+In some cases it may be necessary to debug a test while running it in bazel. For example, when we
+observe a different test result in Eclipse and bazel. Using the `--java_debug` option will start the
+JVM in debug mode and await for a remote debugger to attach.
+
+Example:
+[source,bash]
+----
+  bazel test --java_debug --test_tag_filters=delete-project //...
+  ...
+  Listening for transport dt_socket at address: 5005
+  ...
+----
+
+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
 
@@ -523,10 +569,10 @@
 [[npm-binary]]
 == NPM Binaries
 
-Parts of the PolyGerrit build require running NPM-based JavaScript programs as
-"binaries". We don't attempt to resolve and download NPM dependencies at build
-time, but instead use pre-built bundles of the NPM binary along with all its
-dependencies. Some packages on
+Parts of the Gerrit web app build require running NPM-based JavaScript programs
+as "binaries". We don't attempt to resolve and download NPM dependencies at
+build time, but instead use pre-built bundles of the NPM binary along with all
+its dependencies. Some packages on
 link:https://docs.npmjs.com/misc/registry[registry.npmjs.org,role=external,window=_blank] come with their
 dependencies bundled, but this is the exception rather than the rule. More
 commonly, to add a new binary to this list, you will need to bundle the binary
@@ -589,8 +635,8 @@
 If a npm package has transitive dependencies (or just several files) with a not allowed
 license and you can't avoid use it in release, then you can add this package.
 For example some packages contain demo-code with a different license. Another example - optional
-dependencies, which are not needed to build polygerrit, but they are installed together with
-the package anyway.
+dependencies, which are not needed to build the Gerrit web app, but they are installed together
+with the package anyway.
 
 In this case you should exclude all files and/or transitive dependencies with a not allowed license.
 Adding such package requires additional updates:
@@ -635,6 +681,40 @@
     --disk-size=200
 ```
 
+Due to outdated Git version in official RBE docker images, a custom RBE docker
+image must be used. To build custom docker imager, change to the directory
+`tools/platforms` and build and publish custom RBE docker image.
+
+To build the custom RBE docker image, run:
+
+```
+docker build -t gcr.io/api-project-164060093628/rbe-ubuntu18-04 .
+```
+
+To publish the custom RBE docker image, run:
+
+```
+docker push gcr.io/api-project-164060093628/rbe-ubuntu18-04
+[...]
+latest: digest: sha256:de5186d4313630a6111f9a2449b72563d0bc59ec9fb60956f063b69a38a76834 size: 1584
+```
+
+Re-build rbe_autoconfig project conduct a new release and switch to using it
+in `WORKSPACE` file.
+
+Note, to authenticate to the gcr.io registry, the following command must be
+used:
+
+```
+gcloud auth configure-docker
+```
+
+To see the documentation, developer must be added to this group:
+https://groups.google.com/forum/#!forum/rbe-alpha-customers.
+
+Documentation can be found at:
+https://cloud.google.com/remote-build-execution/docs.
+
 To use RBE, execute
 
 ```
diff --git a/Documentation/dev-community.txt b/Documentation/dev-community.txt
index 53829c9..7488f74 100644
--- a/Documentation/dev-community.txt
+++ b/Documentation/dev-community.txt
@@ -43,7 +43,7 @@
 ** link:dev-contributing.html#mentorship[Mentorship]
 * link:dev-design-docs.html[Design Docs]
 * link:dev-readme.html[Developer Setup]
-* link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui[Polymer Frontend Developer Setup]
+* link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui[TypeScript Frontend Developer Setup]
 * link:dev-crafting-changes.html[Crafting Changes]
 * link:dev-starter-projects.html[Starter Projects]
 
@@ -52,7 +52,7 @@
 * link:dev-plugins-lifecycle.html[Plugin Lifecycle]
 * link:dev-plugins.html[Developing Plugins]
 * link:dev-build-plugins.html[Building Gerrit plugins]
-* link:js-api.html[JavaScript Plugin API]
+* link:pg-plugin-dev.html[JavaScript Plugin Development and API]
 * link:config-validation.html[Validation Interfaces]
 * link:dev-stars.html[Starring Changes]
 * link:quota.html[Quota Enforcement]
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 477641b..01857da 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -113,6 +113,11 @@
 nags and pester you if you haven't replied or made a fix, so it helps
 them know if you missed it or decided against it.
 
+Features or API extensions, even if they are small, will incur
+long-time maintenance and support burden, so they should be left
+pending for at least 24 hours to give maintainers in all timezones a
+chance to evaluate.
+
 [[design-driven-contribution-process]]
 === Design-driven Contribution Process
 
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
index 7935f30..15bf785 100644
--- a/Documentation/dev-crafting-changes.txt
+++ b/Documentation/dev-crafting-changes.txt
@@ -306,6 +306,30 @@
     and it also makes `git revert` more useful.
   * Use topics to link your separate changes together.
 
+[[opportunistic-refactoring]]
+== Opportunistic Refactoring
+
+Opportunistic Refactoring is a terminology
+link:https://martinfowler.com/bliki/OpportunisticRefactoring.html[used by Martin Fowler,role=external,window=_blank]
+also known as the "boy scout rule" of the software developer:
+"always leave the code behind in a better state than you found it."
+
+In practice, this rule means you should not add technical debt in the code while
+implementing a new feature or fixing a bug. If you or a reviewer find an
+opportunity to clean up the code during implementation or review of your change,
+take the time to do a little cleanup to improve the overall code base.
+
+When approaching refactoring, keep in mind that changes should do one thing
+(<<change-size,see change size section above>>). If a change you're making
+requires cleanup/refactoring, it is best to do that cleanup in a preparatory and
+separate change. Likewise, if during review for a functional change, an
+opportunity for cleanup/refactoring is discovered, then it is preferable to do
+the cleanup first in a separate change so as to improve the reviewability of the
+functional change.
+
+Reviewers should keep in mind the scope of the change under review and ensure
+suggested refactoring is aligned with that scope.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-design.txt b/Documentation/dev-design.txt
index 1935586..5636dfd 100644
--- a/Documentation/dev-design.txt
+++ b/Documentation/dev-design.txt
@@ -18,83 +18,45 @@
 
 == Background
 
-Google developed Mondrian, a Perforce based code review tool to
-facilitate peer-review of changes prior to submission to the central
-code repository.  Mondrian is not open source, as it is tied to the
-use of Perforce and to many Google-only services, such as Bigtable.
-Google employees have often described how useful Mondrian and its
-peer-review process is to their day-to-day work.
-
-Guido van Rossum open sourced portions of Mondrian within Rietveld,
-a similar code review tool running on Google App Engine, but for
-use with Subversion rather than Perforce.  Rietveld is in common
-use by many open source projects, facilitating their peer reviews
-much as Mondrian does for Google employees.  Unlike Mondrian and
-the Google Perforce triggers, Rietveld is strictly advisory and
-does not enforce peer-review prior to submission.
-
 Git is a distributed version control system, wherein each repository
 is assumed to be owned/maintained by a single user.  There are no
 inherent security controls built into Git, so the ability to read
 from or write to a repository is controlled entirely by the host's
-filesystem access controls.  When multiple maintainers collaborate
-on a single shared repository a high degree of trust is required,
-as any collaborator with write access can alter the repository.
+filesystem or network access controls.
 
-Gitosis provides tools to secure centralized Git repositories,
-permitting multiple maintainers to manage the same project at once,
-by restricting the access to only over a secure network protocol,
-much like Perforce secures a repository by only permitting access
-over its network port.
+The objective of Gerrit is to facilitate Git development by larger
+teams: it provides a means to enforce organizational policies around
+code submissions, eg. "all code must be reviewed by another
+developer", "all code shall pass tests". It achieves this by
 
-The Android Open Source Project (AOSP) was founded by Google by the
-open source releasing of the Android operating system.  AOSP has
-selected Git as its primary version control tool.  As many of the
-engineers have a background of working with Mondrian at Google,
-there is a strong desire to have the same (or better) feature set
-available for Git and AOSP.
+* providing fine-grained (per-branch, per-repository, inheriting)
+  access controls, which allow a Gerrit admin to delegate permissions
+  to different team(-lead)s.
 
-Gerrit Code Review started as a simple set of patches to Rietveld,
-and was originally built to service AOSP. This quickly turned
-into a fork as we added access control features that Guido van
-Rossum did not want to see complicating the Rietveld code base. As
-the functionality and code were starting to become drastically
-different, a different name was needed. Gerrit calls back to the
-original namesake of Rietveld, Gerrit Rietveld, a Dutch architect.
-
-Gerrit 2.x is a complete rewrite of the Gerrit fork, completely
-changing the implementation from Python on Google App Engine, to Java
-on a J2EE servlet container and an SQL database.
-
-Since Gerrit 3.x link:note-db.html[NoteDb] replaced the SQL database
-and all metadata is now stored in Git.
-
-* link:http://video.google.com/videoplay?docid=-8502904076440714866[Mondrian Code Review On The Web,role=external,window=_blank]
-* link:https://github.com/rietveld-codereview/rietveld[Rietveld - Code Review for Subversion,role=external,window=_blank]
-* link:http://eagain.net/gitweb/?p=gitosis.git;a=blob;f=README.rst;hb=HEAD[Gitosis README,role=external,window=_blank]
-* link:http://source.android.com/[Android Open Source Project,role=external,window=_blank]
-
+* facilitate code review: Gerrit offers a web view of pending code
+  changes, that allows for easy reading and commenting by humans. The
+  web view can offer data coming out of automated QA processes (eg.
+  CI). The permission system also includes fine grained control of who
+  can approve pending changes for submission to further facilitate
+  delegation of code ownership.
 
 == Overview
 
 Developers create one or more changes on their local desktop system,
 then upload them for review to Gerrit using the standard `git push`
-command line program, or any GUI which can invoke `git push` on
-behalf of the user.  Authentication and data transfer are handled
-through SSH.  Users are authenticated by username and public/private
-key pair, and all data transfer is protected by the SSH connection
-and Git's own data integrity checks.
+command line program, or any GUI which can invoke `git push` on behalf
+of the user. Authentication and data transfer are handled through SSH
+and HTTPS. Uploads are protected by the authentication,
+confidentiality and integrity offered by the transport (SSH, HTTPS).
 
-Each Git commit created on the client desktop system is converted
-into a unique change record which can be reviewed independently.
-Change records are stored in NoteDb.
+Each Git commit created on the client desktop system is converted into
+a unique change record which can be reviewed independently.
 
 A summary of each newly uploaded change is automatically emailed
 to reviewers, so they receive a direct hyperlink to review the
 change on the web.  Reviewer email addresses can be specified on the
-`git push` command line, but typically reviewers are automatically
-selected by Gerrit by identifying users who have change approval
-permissions in the project.
+`git push` command line, but typically reviewers are added in the web
+interface.
 
 Reviewers use the web interface to read the side-by-side or unified
 diff of a change, and insert draft inline/file comments where
@@ -103,40 +65,48 @@
 emailed to the change author by Gerrit, and are CC'd to all other
 reviewers who have already commented on the change.
 
-When publishing comments reviewers are also given the opportunity
-to score the change, indicating whether they feel the change is
-ready for inclusion in the project, needs more work, or should be
-rejected outright.  These scores provide direct feedback to Gerrit's
-change submit function.
+Reviewers can score the change ("vote"), indicating whether they feel the
+change is ready for inclusion in the project, needs more work, or
+should be rejected outright. These scores provide direct feedback to
+Gerrit's change submit function.
 
-After a change has been scored positively by reviewers, Gerrit
-enables a submit button on the web interface.  Authorized users
-can push the submit button to have the change enter the project
-repository.  The equivalent in Subversion or Perforce would be
-that Gerrit is invoking `svn commit` or `p4 submit` on behalf of
-the web user pressing the button.  Due to the way Git audit trails
-are maintained, the user pressing the submit button does not need
-to be the author of the change.
+After a change has been scored positively by reviewers, Gerrit enables
+a submit button on the web interface. Authorized users can push the
+submit button to have the change enter the project repository. The
+user pressing the submit button does not need to be the author of the
+change.
 
 
 == Infrastructure
 
 End-user web browsers make HTTP requests directly to Gerrit's
-HTTP server.  As nearly all of the user interface is implemented
-through PolyGerrit, the majority of these requests are transmitting
-compressed JSON payloads, with all HTML being generated within the
-browser.  Most responses are under 1 KB.
+HTTP server. As nearly all of the Gerrit user interface is implemented
+in a JavaScript based web app, the majority of these requests are
+transmitting compressed JSON payloads, with all HTML being generated
+within the browser.
 
-Gerrit's HTTP server side component is implemented as a standard
-Java servlet, and thus runs within any J2EE servlet container.
-Popular choices for deployments would be Tomcat or Jetty, as these
-are high-quality open-source servlet containers that are readily
-available for download.
+Gerrit's HTTP server side component is implemented as a standard Java
+servlet, and thus runs within any link:install-j2ee.html[J2EE servlet
+container]. The standard install will run inside Jetty, which is
+included in the binary.
 
-End-user uploads are performed over SSH, so Gerrit's servlets also
-start up a background thread to receive SSH connections through
-an independent SSH port.  SSH clients communicate directly with
-this port, bypassing the HTTP server used by browsers.
+End-user uploads are performed over SSH or HTTP, so Gerrit's servlets
+also start up a background thread to receive SSH connections through
+an independent SSH port. SSH clients communicate directly with this
+port, bypassing the HTTP server used by browsers.
+
+User authentication is handled by identity realms. Gerrit supports the
+following types of authentication:
+
+* OpenId (see link:http://openid.net/developers/specs/[OpenID Specifications,role=external,window=_blank])
+* OAuth2
+* LDAP
+* Google accounts (on googlesource.com)
+* SAML
+* Kerberos
+* 3rd party SSO
+
+=== NoteDb
 
 Server side data storage for Gerrit is broken down into two different
 categories:
@@ -156,28 +126,119 @@
 local ones, due to Git disk IO behavior not being optimized for
 remote access.
 
-The Gerrit metadata contains a summary of the available changes,
-all comments (published and drafts), and individual user account
-information.  The metadata is mostly housed in the database (*1),
-which can be located either on the same server as Gerrit, or on
-a different (but nearby) server.  Most installations would opt to
-install both Gerrit and the metadata database on the same server,
-to reduce administration overheads.
+The Gerrit metadata contains a summary of the available changes, all
+comments (published and drafts), and individual user account
+information.
 
-User authentication is handled by OpenID, and therefore Gerrit
-requires that the OpenID provider selected by a user must be
-online and operating in order to authenticate that user.
+Gerrit metadata is also stored in Git, with the commits marking the
+historical state of metadata. Data is stored in the trees associated
+with the commits, typically using Git config file or JSON as the base
+format. For metadata, there are 3 types of data: changes, accounts and
+groups.
 
-* link:http://www.kernel.org/pub/software/scm/git/docs/gitrepository-layout.html[Git Repository Format,role=external,window=_blank]
-* link:http://openid.net/developers/specs/[OpenID Specifications,role=external,window=_blank]
+Accounts are stored in a special Git repository `All-Users`.
 
-*1  Although an effort is underway to eliminate the use of the
-database altogether, and to store all the metadata directly in
-the git repositories themselves.  So far, as of Gerrit 2.2.1, of
-all Gerrit's metadata, only the project configuration metadata
-has been migrated out of the database and into the git
-repositories for each project.
+Accounts can be grouped in groups. Gerrit has a built-in group system,
+but can also interface to external group system (eg. Google groups,
+LDAP). The built-in groups are stored in `All-Users`.
 
+Draft comments are stored in `All-Users` too.
+
+Permissions are stored in Git, in a branch `refs/meta/config` for the
+repository. Repository configuration (including permissions) supports
+single inheritance, with the `All-Projects` repository containing
+site-wide defaults.
+
+Code review metadata is stored in Git, alongside the code under
+review. Metadata includes change status, votes, comments. This review
+metadata is stored in NoteDb along with the submitted code and code
+under review. Hence, the review history can be exported with `git
+clone --mirror` by anyone with sufficient permissions.
+
+== Permissions
+
+Permissions are specified on branch names, and given to groups. For
+example,
+
+```
+[access "refs/heads/stable/*"]
+        push = group Release-Engineers
+```
+
+this provides a rule, granting Release-Engineers push permission for
+stable branches.
+
+There are fundamentally two types of permissions:
+
+* Write permissions (who can vote, push, submit etc.)
+
+* Read permissions (who can see data)
+
+Read permissions need special treatment across Gerrit, because Gerrit
+should only surface data (including repository existence) if a user
+has read permission. This means that
+
+* The git wire protocol support must omit references from
+  advertisement if the user lacks read permissions
+
+* Uploads through the git wire protocol must refuse commits that are
+  based on SHA-1s for data that the user can't see.
+
+* Tags are only visible if their commits are visible to user through a
+  non-tag reference.
+
+Metadata (eg. OAuth credentials) is also stored in Git. Existing
+endpoints must refuse creating branches or changes that expose these
+metadata or allow changes to them.
+
+
+=== Indexing
+
+Almost all data is stored as Git, but Git only supports fast lookup by
+SHA-1 or by ref (branch) name. Therefore Gerrit also has an indexing
+system (powered by Lucene by default) for other types of queries.
+There are 4 indices:
+
+* Project index - find repositories by name, parent project, etc.
+* Account index - find accounts by name, email, etc.
+* Group index - find groups by name, owner, description etc.
+* Change index - find changes by file, status, modification date etc.
+
+The base entities are characterized by SHA-1s. Storing the
+characterizing SHA-1s allows detection of stale index entries.
+
+== Plug-in architecture
+
+Gerrit has a plug-in architecture. Plugins can be installed by
+dropping them into $site_directory/plugins, or at runtime through
+plugin SSH commands, or the plugin REST API.
+
+=== Backend plugins
+
+At runtime, code can be loaded from a `.jar` file. This code can hook
+into predefined extension points. A common use of plugins is to have
+Gerrit interoperate with site-specific tools, such as CI-systems or
+issue trackers.
+
+// list some notable extension points, and notable plugins
+// link to plugin development
+
+Some backend plugins expose the JVM for scripting use (eg. Groovy,
+Scala), so plugins can be written without having to setup a Java
+development environment.
+
+// Luca to expand: how do script plugins load their scripts?
+
+=== Frontend plugins
+
+The UI can be extended using Frontend plugins. This is useful for
+changing the look & feel of Gerrit, but it can also be used to surface
+data from systems that aren't integrated with the Gerrit backend, eg.
+CI systems or code coverage providers.
+
+// FE team to write a bit more:
+// * how to load ?
+// * XSRF, CORS ?
 
 == Internationalization and Localization
 
@@ -189,14 +250,11 @@
 and comments in English, and therefore an English user interface
 is usable by the target user base.
 
-Right-to-left (RTL) support is only barely considered within the
-Gerrit code base.  Some portions of the code have tried to take
-RTL into consideration, while others probably need to be modified
-before translating the UI to an RTL language.
-
 
 == Accessibility Considerations
 
+// UI team to rewrite this.
+
 Whenever possible Gerrit displays raw text rather than image icons,
 so screen readers should still be able to provide useful information
 to blind persons accessing Gerrit sites.
@@ -215,7 +273,9 @@
 
 == Browser Compatibility
 
-Supporting non-JavaScript enabled browsers is a non-goal for Gerrit.
+Gerrit requires a JavaScript enabled browser.
+
+// UI team to add section on minimum browser requirements.
 
 As Gerrit is a pure JavaScript application on the client side, with
 no server side rendering fallbacks, the browser must support modern
@@ -223,50 +283,19 @@
 Dumb clients such as `lynx`, `wget`, `curl`, or even many search engine
 spiders are not able to access Gerrit content.
 
-There are number of web browsers available with full JavaScript
-support, and nearly every operating system (including any PDA-like
-mobile phone) comes with one standard.  Users who are committed
-to developing changes for a Gerrit managed project can be expected
-to be able to run a JavaScript enabled browser, as they also would
-need to be running Git in order to contribute.
-
-There are a number of open source browsers available, including
-Firefox and Chromium.  Users have some degree of choice in their
-browser selection, including being able to build and audit their
-browser from source.
-
-The majority of the content stored within Gerrit is also available
-through other means, such as gitweb or the `git://` protocol.
-Any existing search engine spider can crawl the server-side HTML
-produced by gitweb, and thus can index the majority of the changes
-which might appear in Gerrit.  Some engines may even choose to
-crawl the native version control database, such as ohloh.net does.
-Therefore the lack of support for most search engine spiders is a
-non-issue for most Gerrit deployments.
+All of the content stored within Gerrit is also available through
+other means, such as gitweb or the `git://` protocol. Any existing
+search engine crawlers can index the server-side HTML served by a code
+browser, and thus can index the majority of the changes which might
+appear in Gerrit. Therefore the lack of support for most search engine
+crawlers is a non-issue for most Gerrit deployments.
 
 
 == Product Integration
 
-Gerrit integrates with an existing gitweb installation by optionally
-creating hyperlinks to reference changes on the gitweb server.
-
-Gerrit integrates with an existing git-daemon installation by
-optionally displaying `git://` URLs for users to download a
-change through the native Git protocol.
-
-Gerrit integrates with any OpenID provider for user authentication,
-making it easier for users to join a Gerrit site and manage their
-authentication credentials to it.
-
-Site administrators may limit the range of OpenID providers to
-a subset of "reliable providers".  Users may continue to use
-any OpenID provider to publish comments, but granted privileges
-are only available to a user if the only entry point to their
-account is through the defined set of "reliable OpenID providers".
-This permits site administrators to require HTTPS for OpenID,
-and to use only large main-stream providers that are trustworthy,
-or to require users to only use a custom OpenID provider installed
-alongside Gerrit Code Review.
+Gerrit optionally surfaces links to HTML pages in a code browser. The
+links are configurable, and Gerrit comes with a built-in code browser,
+called Gitiles.
 
 Gerrit integrates with some types of corporate single-sign-on (SSO)
 solutions, typically by having the SSO authentication be performed
@@ -286,16 +315,17 @@
 Gerrit does not integrate with any Google service, or any other
 services other than those listed above.
 
+Plugins (see above) can be used to drive product integrations from the
+Gerrit side. Products that support Gerrit explicitly can use the REST
+API or the SSH API to contact Gerrit.
+
+
 == Privacy Considerations
 
 Gerrit stores the following information per user account:
 
 * Full Name
 * Preferred Email Address
-* Mailing Address '(Optional, Encrypted)'
-* Country '(Optional, Encrypted)'
-* Phone Number '(Optional, Encrypted)'
-* Fax Number '(Optional, Encrypted)'
 
 The full name and preferred email address fields are shown to any
 site visitor viewing a page containing a change uploaded by the
@@ -321,271 +351,145 @@
 The user's name and email address is stored unencrypted in the
 link:config-accounts.html#all-users[All-Users] repository.
 
-The snail-mail mailing address, country, and phone and fax numbers
-are gathered to help project leads contact the user should there
-be a legal question regarding any change they have uploaded.
-
-These sensitive fields are immediately encrypted upon receipt with
-a GnuPG public key, and stored "off site" in another data store,
-isolated from the main Gerrit change data.  Gerrit does not have
-access to the matching private key, and as such cannot decrypt the
-information.  Therefore these fields are write-once in Gerrit, as not
-even the account owner can recover the values they previously stored.
-
-It is expected that the address information would only need to be
-decrypted and revealed with a valid court subpoena, but this is
-really left to the discretion of the Gerrit site administrator as
-to when it is reasonable to reveal this information to a 3rd party.
-
-
 == Spam and Abuse Considerations
 
-Gerrit makes no attempt to detect spam changes or comments.  The
-somewhat high barrier to entry makes it unlikely that a spammer
-will target Gerrit.
+There is no spam protection for the Git protocol upload path.
+Uploading a change successfully requires a pre-existing account, and a
+lot of up-front effort.
 
-To upload a change, the client must speak the native Git protocol
-embedded in SSH, with some custom Gerrit semantics added on top.
-The client must have their public key already stored in the Gerrit
-database, which can only be done through the XSRF protected
-JSON-RPC interface.  The level of effort required to construct
-the necessary tools to upload a well-formatted change that isn't
-rejected outright by the Git and Gerrit checksum validations is
-too high to for a spammer to get any meaningful return.
+Gerrit makes no attempt to detect spam changes or comments in the web
+UI. To post and publish a comment a client must sign in and then use
+the XSRF protected JSON-RPC interface to publish the draft on an
+existing change record.
 
-To post and publish a comment a client must sign in with an OpenID
-provider and then use the XSRF protected JSON-RPC interface to
-publish the draft on an existing change record.  Again, the level of
-effort required to implement the Gerrit specific XSRF protections
-and the JSON-RPC payload format necessary to post a draft and then
-publish that draft is simply too high for a spammer to bother with.
-
-Both of these assumptions are also based upon the idea that Gerrit
-will be a lot less popular than blog software, and thus will be
-running on a lot fewer websites.  Spammers therefore have very little
-returned benefit for getting over the protocol hurdles.
-
-These assumptions may need to be revisited in the future if any
-public Gerrit site actually notices spam.
-
-
-== Latency
-
-Gerrit targets for sub-250 ms per page request, mostly by using
-very compact JSON payloads between client and server.  However, as
-most of the serving stack (network, hardware, metadata
-database) is out of control of the Gerrit developers, no real
-guarantees can be made about latency.
+Absence of SPAM handling is based upon the idea that Gerrit caters to
+a niche audience, and will therefore be unattractive to spammers. In
+addition, it is not a factor for corporate, on-premise deployments.
 
 
 == Scalability
 
-Gerrit is designed for a very large scale open source project, or
-large commercial development project.  Roughly this amounts to
-parameters such as the following:
+Gerrit supports the Git wire protocol, and an API (one API for HTTP,
+and one for SSH).
 
-.Design Parameters
-[options="header"]
-|======================================================
-|Parameter        | Default Maximum | Estimated Maximum
-|Projects         |         1,000   | 10,000
-|Contributors     |         1,000   | 50,000
-|Changes/Day      |           100   |  2,000
-|Revisions/Change |            20   |     20
-|Files/Change     |            50   | 16,000
-|Comments/File    |           100   |    100
-|Reviewers/Change |             8   |      8
-|======================================================
+The git wire protocol does a client/server negotiation to avoid
+sending too much data. This negotation occupies a CPU, so the number
+of concurrent push/fetch operations should be capped by the number of
+CPUs.
 
-Out of the box, Gerrit will handle the "Default Maximum". Site
-administrators may reconfigure their servers by editing gerrit.config
-to run closer to the estimated maximum if sufficient memory is made
-available to the JVM and the relevant cache.*.memoryLimit variables
-are increased from their defaults.
-
-=== Discussion
-
-Very few, if any open source projects have more than a handful of
-Git repositories associated with them.  Since Gerrit treats each
-Git repository as a project, an upper limit of 10,000 projects
-is reasonable.  If a site has more than 1,000 projects, administrators
-should increase
-link:config-gerrit.html#cache.name.memoryLimit[`cache.projects.memoryLimit`]
-to match.
-
-Almost no open source project has 1,000 contributors over all time,
-let alone on a daily basis.  This default figure of 1,000 was WAG'd by
-looking at PR statements published by cell phone companies picking
-up the Android operating system.  If all of the stated employees in
-those PR statements were working on *only* the open source Android
-repositories, we might reach the 1,000 estimate listed here.  Knowing
-these companies as being very closed-source minded in the past, it
-is very unlikely all of their Android engineers will be working on
-the open source repository, and thus 1,000 is a very high estimate.
-
-The upper maximum of 50,000 contributors is based on existing
-installations that are already handling quite a bit more than the
-default maximum of 1,000 contributors. Given how the user data is
-stored and indexed, supporting 50,000 contributor accounts (or more)
-is easily possible for a server. If a server has more than 1,000
-*active* contributors,
-link:config-gerrit.html#cache.name.memoryLimit[`cache.accounts.memoryLimit`]
-should be increased by the site administrator, if sufficient RAM
-is available to the host JVM.
-
-The estimate of 100 changes per day was WAG'd off some estimates
-originally obtained from Android's development history.  Writing a
-good change that will be accepted through a peer-review process
-takes time.  The average engineer may need 4-6 hours per change just
-to write the code and unit tests.  Proper design consideration and
-additional but equally important tasks such as meetings, interviews,
-training, and eating lunch will often pad the engineer's day out
-such that suitable changes are only posted once a day, or once
-every other day.  For reference, the entire Linux kernel has an
-average of only 79 changes/day. If more than 100 changes are active
-per day, site administrators should consider increasing the
-link:config-gerrit.html#cache.name.memoryLimit[`cache.diff.memoryLimit`]
-and `cache.diff_intraline.memoryLimit`.
-
-On average any given change will need to be modified once to address
-peer review comments before the final revision can be accepted by the
-project.  Executing these revisions also eats into the contributor's
-time, and is another factor limiting the number of changes/day
-accepted by the Gerrit instance.  However, even though this implies
-only 2 revisions/change, many existing Gerrit installations have seen
-20 or more revisions/change, when new contributors are learning the
-project's style and conventions.
-
-On average, each change will have 2 reviewers, a human and an
-automated test bed system.  Usually this would be the project lead, or
-someone who is familiar with the code being modified.  The time
-required to comment further reduces the time available for writing
-one's own changes.  However, existing Gerrit installations have seen 8
-or more reviewers frequently show up on changes that impact many
-functional areas, and therefore it is reasonable to expect 8 or more
-reviewers to be able to work together on a single change.
-
-Existing installations have successfully processed change reviews with
-more than 16,000 files per change. However, since 16,000 modified/new
-files is a massive amount of code to review, it is more typical to see
-less than 10 files modified in any single change. Changes larger than
-10 files are typically merges, for example integrating the latest
-version of an upstream library, where the reviewer has little to do
-beyond verifying the project compiles and passes a test suite.
-
-=== CPU Usage - Web UI
-
-Gerrit's web UI would require on average `4+F+F*C` HTTP requests to
-review a change and post comments.  Here `F` is the number of files
-modified by the change, and `C` is the number of inline/file comments
-left by the reviewer per file.  The constant 4 accounts for the request
-to load the reviewer's dashboard, to load the change detail page,
-to publish the review comments, and to reload the change detail
-page after comments are published.
-
-This WAG'd estimate boils down to 216,000 HTTP requests per day
-(QPD). Assuming these are evenly distributed over an 8 hour work day
-in a single time zone, we are looking at approximately 7.5 queries
-per second (QPS).
-
-----
-  QPD = Changes_Day * Revisions_Change * Reviewers_Change * (4 +  F +  F * C)
-      = 2,000       * 2                * 1                * (4 + 10 + 10 * 4)
-      = 216,000
-  QPS = QPD / 8_Hours / 60_Minutes / 60_Seconds
-      = 7.5
-----
-
-Gerrit serves most requests in under 60 ms when using the loopback
-interface and a single processor.  On a single CPU system there is
-sufficient capacity for 16 QPS.  A dual processor system should be
-more than sufficient for a site with the estimated load described above.
-
-Given a more realistic estimate of 79 changes per day (from the
-Linux kernel) suggests only 8,532 queries per day, and a much lower
-0.29 QPS when spread out over an 8 hour work day.
-
-=== CPU Usage - Git over SSH/HTTP
-
-A 24 core server is able to handle ~25 concurrent `git fetch`
-operations per second. The issue here is each concurrent operation
-demands one full core, as the computation is almost entirely server
-side CPU bound. 25 concurrent operations is known to be sufficient to
-support hundreds of active developers and 50 automated build servers
-polling for updates and building every change.  (This data was derived
-from an actual installation's performance.)
-
-Because of the distributed nature of Git, end-users don't need to
-contact the central Gerrit Code Review server very often. For `git
-fetch` traffic, link:pgm-daemon.html[replica mode] is known to be an
-effective way to offload traffic from the main server, permitting it
-to scale to a large user base without needing an excessive number of
-cores in a single system.
-
-Clients on very slow network connections (for example home office
-users on VPN over home DSL) may be network bound rather than server
-side CPU bound, in which case a core may be effectively shared with
-another user. Possible core sharing due to network bottlenecks
+Clients on slow network connections may be network bound rather than
+server side CPU bound, in which case a core may be effectively shared
+with another user. Possible core sharing due to network bottlenecks
 generally holds true for network connections running below 10 MiB/sec.
 
-If the server's own network interface is 1 Gib/sec (Gigabit Ethernet),
-the system can really only serve about 10 concurrent clients at the
-10 MiB/sec speed, no matter how many cores it has.
+Deployments for large, distributed companies can replicate Git data to
+read-only replicas to offload fetch traffic. The read-only replicas
+should also serve this data using Gerrit to ensure that permissions
+are obeyed.
 
-=== Disk Usage
+The API serves requests of varying costs. Requests that originate in
+the UI can block productivity, so care has been taken to optimize
+these for latency, using the following techniques:
 
-The average size of a revision in the Linux kernel once compressed by
-Git is 2,327 bytes, or roughly 2 KiB.  Over the course of a year a
-Gerrit server running with the estimated maximum parameters above might
-see an introduction of 1.4 GiB over the total set of 10,000 projects
-hosted in that server.  This figure assumes the majority of the content
-is human written source code, and not large binary blobs such as disk
-images or media files.
+* Async calls: the UI becomes responsive before some UI elements
+  finished loading
 
-Production Gerrit installations have been tested, and are known to
-handle Git repositories in the multigigabyte range, storing binary
-files, ranging in size from a few kilobytes (for example compressed
-icons) to 800+ megabytes (firmware images, large uncompressed original
-artwork files).  Best practices encourage breaking very large binary
-files into their Git repositories based on access, to prevent desktop
-clients from needing to clone unnecessary materials (for example a C
-developer does not need every 800+ megabyte firmware image created by
-the product's quality assurance team).
+* Caching: metadata is stored in Git, which is relatively expensive to
+  access. This is sped up by multiple caches. Metadata entities are
+  stored in Git, and can therefore be seen as immutable values keyed
+  by SHA-1, which is very amenable to caching. All SHA-1 keyed caches
+  can be persisted on local disk.
+
+  The size (memory, disk) of these caches should be adapted to the
+  instance size (number of users, size and quantity of repositories)
+  for optimal performance.
+
+Git does not impose fundamental limits (eg. number of files per
+change) on data. To ensure stability, Gerrit configures a number of
+default limits for these.
+
+// add a link to the default settings.
+
+=== Scaling team size
+
+A team of size N has N^2 possible interactions. As a result, features
+that expose interactions with activities of other team members has a
+quadratic cost in aggregate. The following features scale poorly with
+large team sizes:
+
+* the change screen shows conflicting changes by default. This data is
+  cached, but updates to pending changes cause cache misses. For a
+  single change, the amount of work is proportional to the number of
+  pending changes, so in aggregate, the cost of this feature is
+  quadratic in the team size.
+
+* the change screen shows if a change is mergeable to the target
+  branch. If the target branch moves quickly (large developer team),
+  this causes cache misses. In aggregate, the cost of this feature is
+  also quadratic.
+
+Both features should be turned off for repositories that involve 1000s
+of developers.
+
+=== Browser performance
+
+// say something about browser performance tuning.
+
+=== Real life numbers
+
+
+Gerrit is designed for very large projects, both open source and
+proprietary commercial projects. For a single Gerrit process, the
+following limits are known to work:
+
+.Observed maximums
+[options="header"]
+|======================================================
+|Parameter        |         Maximum | Deployment
+|Projects         |         50,000  | gerrithub.io
+|Contributors     |        150,000  | eclipse.org
+|Bytes/repo       |        100G     | Qualcomm internal
+|Changes/repo     |        300k     | Qualcomm internal
+|Revisions/Change |        300      | Qualcomm internal
+|Reviewers/Change |        87       | Qualcomm internal
+|======================================================
+
+
+// find some numbers for these stats:
+// |Files/repo       |        ? |
+// |Files/Change     |        ? |
+// |Comments/Change  |        ? |
+// |max QPS/CPU      |        ? |
+
+
+Google runs a horizontally scaled deployment. We have seen the
+following per-JVM maximums:
+
+.Observed maximums (googlesource.com)
+[options="header"]
+|======================================================
+|Parameter        |         Maximum | Deployment
+|Files/repo       |        500,000  | chromium-review
+|Bytes/repo       |         12G     | chromium-review
+|Changes/repo     |          500k   | chromium-review
+|Revisions/Change |          1900   | chromium-review
+|Files/Change     |           10,000| android-review
+|Comments/Change  |           1,200 | chromium-review
+|======================================================
+
 
 == Redundancy & Reliability
 
-Gerrit largely assumes that the local filesystem where Git repository
-data is stored is always available.  Important data written to disk
-is also forced to the platter with an `fsync()` once it has been
-fully written.  If the local filesystem fails to respond to reads
-or becomes corrupt, Gerrit has no provisions to fallback or retry
-and errors will be returned to clients.
+Gerrit is structured as a single JVM process, reading and writing to a
+single file system. If there are hardware failures in the machine
+running the JVM, or the storage holding the repositories, there is no
+recourse; on failure, errors will be returned to the client.
 
-Gerrit largely assumes that the metadata database is online and
-answering both read and write queries.  Query failures immediately
-result in the operation aborting and errors being returned to the
-client, with no retry or fallback provisions.
+Deployments needing more stringent uptime guarantees can use
+replication/multi-master setup, which ensures availability and
+geographical distribution, at the cost of slower write actions.
 
-Due to the relatively small scale described above, it is very likely
-that the Git filesystem and metadata database are all housed on the
-same server that is running Gerrit.  If any failure arises in one of
-these components, it is likely to manifest in the others too.  It is
-also likely that the administrator cannot be bothered to deploy a
-cluster of load-balanced server hardware, as the scale and expected
-load does not justify the hardware or management costs.
-
-Most deployments caring about reliability will setup a warm-spare
-standby system and use a manual fail-over process to switch from the
-failed system to the warm-spare.
-
-As Git is a distributed version control system, and open source
-projects tend to have contributors from all over the world, most
-contributors will be able to tolerate a Gerrit down time of several
-hours while the administrator is notified, signs on, and brings the
-warm-spare up.  Pending changes are likely to need at least 24 hours
-of time on the Gerrit site anyway in order to ensure any interested
-parties around the world have had a chance to comment.  This expected
-lag largely allows for some downtime in a disaster scenario.
+// TODO: link.
 
 === Backups
 
@@ -599,7 +503,8 @@
 
 == Logging Plan
 
-Gerrit does not maintain logs on its own.
+Gerrit stores Apache style HTTPD logs, as well as ERROR/INFO messages
+from the Java logger, under `$site_dir/logs/`.
 
 Published comments contain a publication date, so users can judge
 when the comment was posted and decide if it was "recent" or not.
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index bbe227a..e18d7b0 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -118,7 +118,7 @@
 
 == Testing
 
-=== PolyGerrit UI is served by `server.go` process. To launch it,
+=== The Gerrit web app UI is served by `server.go` process. To launch it,
 run this command:
 
 ----
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 03e8ce6..3d0d6f9 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -5,8 +5,8 @@
 This page describes how plugins for Gerrit can be developed and hosted
 on gerrit-review.googlesource.com.
 
-For PolyGerrit-specific plugin development, consult with
-link:pg-plugin-dev.html[PolyGerrit Plugin Development] guide.
+For JavaScript plugin development, consult with
+link:pg-plugin-dev.html[JavaScript Plugin Development] guide.
 
 Depending on how tightly the extension code is coupled with the Gerrit
 server code, there is a distinction between `plugins` and `extensions`.
@@ -809,6 +809,35 @@
 }
 ----
 
+To provide additional Guice bindings for options to a command in another classloader, bind a
+ModulesClassNamesProvider which provides the name of your Modules needed for your DynamicBean
+in the other classLoader.
+
+Do this by binding to the name of the command you are going to bind to and providing an
+Iterable of Module names to instantiate and add to the Injector used to instantiate the
+DynamicBean in the other classLoader. This interface supports running LifecycleListeners
+which are defined by the Modules being provided. The duration of the lifecycle starts when
+a ssh or http request starts and ends when the request completes.
+
+[source, java]
+----
+bind(DynamicOptions.DynamicBean.class)
+    .annotatedWith(Exports.named(
+        "com.google.gerrit.plugins.otherplugin.command"))
+    .to(MyOptionsModulesClassNamesProvider.class);
+
+static class MyOptionsModulesClassNamesProvider implements DynamicOptions.ModulesClassNamesProvider {
+  @Override
+  public String getClassName() {
+    return "com.googlesource.gerrit.plugins.myplugin.CommandOptions";
+  }
+  @Override
+  public Iterable<String> getModulesClassNames()() {
+    return "com.googlesource.gerrit.plugins.myplugin.MyOptionsModule";
+  }
+}
+----
+
 Plugins can receive a bean object for each of the gerrit ssh and the REST API
 commands by implementing BeanParseListener interface and registering it to a
 command class name in the plugin module's `configure()` method. The below
@@ -1034,17 +1063,9 @@
 Runtime exceptions generated by the implementors of ChangePluginDefinedInfoFactory
 are encapsulated in PluginDefinedInfo objects which are part of SSH/REST query output.
 
-=== ChangeAttributeFactory
-
-Alternatively, there is also `ChangeAttributeFactory` which takes in one single
-`ChangeData` at a time. `ChangePluginDefinedInfoFactory` should be preferred
-over this as it handles many changes at once which also decreases the round-trip
-time for queries resulting in performance increase for bulk queries.
-
-Implementors of the `ChangePluginDefinedInfoFactory` and `ChangeAttributeFactory`
-interfaces should check whether they need to contribute to the
-link:#change-etag-computation[change ETag computation] to prevent callers using
-ETags from potentially seeing outdated plugin attributes.
+Implementors of the `ChangePluginDefinedInfoFactory` interface should check whether
+they need to contribute to the link:#change-etag-computation[change ETag computation]
+to prevent callers using ETags from potentially seeing outdated plugin attributes.
 
 [[simple-configuration]]
 == Simple Configuration in `gerrit.config`
@@ -1480,141 +1501,22 @@
   [...]
 ----
 
+[post_review_extensions]
+== Post Review Extensions
+
+By implementing the `com.google.gerrit.server.restapi.change.OnPostReview`
+interface plugins can extend the change message that is being posted when the
+link:rest-api-changes.html#set-review[post review] REST endpoint is invoked.
+
+This is useful if certain approvals have a special meaning (e.g. custom logic
+that is implemented in Prolog submit rules, signal for triggering an action
+like running CI etc.), as it allows the plugin to tell users about this meaning
+in the change message. This makes the effect of a given approval more
+transparent to the user.
+
 [[ui_extension]]
 == UI Extension
 
-[[panels]]
-=== Panels
-
-UI plugins can contribute panels to Gerrit screens.
-
-Gerrit screens define extension points where plugins can add GWT
-panels with custom controls:
-
-* Change Screen:
-** `GerritUiExtensionPoint.CHANGE_SCREEN_HEADER`:
-+
-Panel will be shown in the header bar to the right of the change
-status.
-
-** `GerritUiExtensionPoint.CHANGE_SCREEN_HEADER_RIGHT_OF_BUTTONS`:
-+
-Panel will be shown in the header bar on the right side of the buttons.
-
-** `GerritUiExtensionPoint.CHANGE_SCREEN_HEADER_RIGHT_OF_POP_DOWNS`:
-+
-Panel will be shown in the header bar on the right side of the pop down
-buttons.
-
-** `GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK`:
-+
-Panel will be shown below the commit info block.
-
-** `GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK`:
-+
-Panel will be shown below the change info block.
-
-** `GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_RELATED_INFO_BLOCK`:
-+
-Panel will be shown below the related info block.
-
-** `GerritUiExtensionPoint.CHANGE_SCREEN_HISTORY_RIGHT_OF_BUTTONS`:
-+
-Panel will be shown in the history bar on the right side of the buttons.
-
-** The following parameters are provided:
-*** `GerritUiExtensionPoint.Key.CHANGE_INFO`:
-+
-The link:rest-api-changes.html#change-info[ChangeInfo] entity for the
-current change.
-+
-The link:rest-api-changes.html#revision-info[RevisionInfo] entity for
-the current patch set.
-
-* Project Info Screen:
-** `GerritUiExtensionPoint.PROJECT_INFO_SCREEN_TOP`:
-+
-Panel will be shown at the top of the screen.
-
-** `GerritUiExtensionPoint.PROJECT_INFO_SCREEN_BOTTOM`:
-+
-Panel will be shown at the bottom of the screen.
-
-** The following parameters are provided:
-*** `GerritUiExtensionPoint.Key.PROJECT_NAME`:
-+
-The name of the project.
-
-* User Password Screen:
-** `GerritUiExtensionPoint.PASSWORD_SCREEN_BOTTOM`:
-+
-Panel will be shown at the bottom of the screen.
-
-** The following parameters are provided:
-*** `GerritUiExtensionPoint.Key.ACCOUNT_INFO`:
-+
-The link:rest-api-accounts.html#account-info[AccountInfo] entity for
-the current user.
-
-* User Preferences Screen:
-** `GerritUiExtensionPoint.PREFERENCES_SCREEN_BOTTOM`:
-+
-Panel will be shown at the bottom of the screen.
-
-** The following parameters are provided:
-*** `GerritUiExtensionPoint.Key.ACCOUNT_INFO`:
-+
-The link:rest-api-accounts.html#account-info[AccountInfo] entity for
-the current user.
-
-* User Profile Screen:
-** `GerritUiExtensionPoint.PROFILE_SCREEN_BOTTOM`:
-+
-Panel will be shown at the bottom of the screen below the grid with the
-profile data.
-
-** The following parameters are provided:
-*** `GerritUiExtensionPoint.Key.ACCOUNT_INFO`:
-+
-The link:rest-api-accounts.html#account-info[AccountInfo] entity for
-the current user.
-
-Example panel:
-[source,java]
-----
-public class MyPlugin extends PluginEntryPoint {
-  @Override
-  public void onPluginLoad() {
-    Plugin.get().panel(GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK,
-        "my_panel_name",
-        new Panel.EntryPoint() {
-          @Override
-          public void onLoad(Panel panel) {
-            panel.setWidget(new InlineLabel("My Panel for change "
-                + panel.getInt(GerritUiExtensionPoint.Key.CHANGE_ID, -1));
-          }
-        });
-  }
-}
-----
-
-Change Screen panel ordering may be specified in the
-project config. Values may be either "plugin name" or
-"plugin name"."panel name".
-Panels not specified in the config will be added
-to the end in load order. Panels specified in the config that
-are not found will be ignored.
-
-Example config:
-----
-[extension-panels "CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK"]
-  panel = helloworld.change_id
-  panel = myotherplugin
-  panel = myplugin.my_panel_name
-----
-
-
-
 [[actions]]
 === Actions
 
@@ -2249,7 +2151,7 @@
 light-weight plugin that links commits to external
 tools (GitBlit, CGit, company specific resources etc).
 
-PatchSetWebLinks will appear to the right of the commit-SHA1 in the UI.
+PatchSetWebLinks will appear to the right of the commit-SHA-1 in the UI.
 
 [source, java]
 ----
@@ -2264,7 +2166,8 @@
   private String imageUrl = "http://placehold.it/16x16.gif";
 
   @Override
-  public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
+  public WebLinkInfo getPatchSetWebLink(String projectName, String commit,
+   String commitMessage, String branchName) {
     return new WebLinkInfo(name,
         imageUrl,
         String.format(placeHolderUrlProjectCommit, project, commit),
@@ -2273,7 +2176,7 @@
 }
 ----
 
-ParentWebLinks will appear to the right of the SHA1 of the parent
+ParentWebLinks will appear to the right of the SHA-1 of the parent
 revisions in the UI. The implementation should in most use cases direct
 to the same external service as PatchSetWebLink; it is provided as a
 separate interface because not all users want to have links for the
@@ -2479,6 +2382,32 @@
 Macros that start with `\` such as `\@KEEP@` will render as `@KEEP@`
 even if there is an expansion for `KEEP` in the future.
 
+Documentation should typically contain the following content:
+
+[width="100%",options="header"]
+|===================================================
+|File                                           | Content
+|`README.md`                                    | Home page of the plugin when browsing its source code on Git
+|`LICENSE`                                      | Open-source license
+|`resources/Documentation/about.md`             | Overview of the plugin and its purpose
+|`resources/Documentation/config.md`            | Plugin configuration settings and sample configs
+|`resources/Documentation/build.md`             | How to build the plugin
+|`resources/Documentation/cmd-<command>.md`     | SSH commands
+|`resources/Documentation/rest-api-<api>.md`    | REST API
+|`resources/Documentation/servlet-<servlet>.md` | HTTP Servlets
+|===================================================
+
+The documentation under resources/Documentation may contain macro that
+will be included and expanded by Gerrit once the plugin is loaded.
+
+The files in the root directory are not included in the plugin package
+and must not have any macro for expansion. It may also collect
+additional information that would make the plugin more discoverable, such as
+a more user-friendly description of its use-cases.
+
+The documentation can also include images that can help understanding more
+visually how the plugin can interact with the other Gerrit components.
+
 [[auto-index]]
 === Automatic Index
 
@@ -2537,23 +2466,18 @@
 Compiled plugins and extensions can be deployed to a running Gerrit
 server using the link:cmd-plugin-install.html[plugin install] command.
 
-Web UI plugins distributed as a single `.js` file (or `.html` file for
-Polygerrit) can be deployed without the overhead of JAR packaging. For
-more information refer to link:cmd-plugin-install.html[plugin install]
-command.
+Web UI plugins distributed as a single `.js` file can be deployed without the
+overhead of JAR packaging. For more information refer to
+link:cmd-plugin-install.html[plugin install] command.
 
 Plugins can also be copied directly into the server's directory at
-`$site_path/plugins/$name.(jar|js|html)`. For Web UI plugins, the name
-of the file, minus the `.js` or `.html` extension, will be used as the
+`$site_path/plugins/$name.(jar|js)`. For Web UI plugins, the name
+of the file, minus the `.js` extension, will be used as the
 plugin name. For JAR plugins, the value of the `Gerrit-PluginName`
 manifest attribute will be used, if provided, otherwise the name of
 the file, minus the `.jar` extension, will be used.
 
-For Web UI plugins, the plugin version is derived from the filename.
-If the filename contains one or more hyphens, the version is taken
-from the portion following the last hyphen. For example if the plugin
-filename is `my-plugin-1.0.js` the version will be `1.0`. For JAR
-plugins, the version is taken from the `Version` attribute in the
+For JAR plugins, the version is taken from the `Version` attribute in the
 manifest.
 
 Unless disabled, servers periodically scan the `$site_path/plugins`
@@ -2984,7 +2908,7 @@
 
 == SEE ALSO
 
-* link:js-api.html[JavaScript API]
+* link:pg-plugin-dev.html[JavaScript Plugin API]
 * link:dev-rest-api.html[REST API Developers' Notes]
 
 GERRIT
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index 5049831..e43e021 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -89,6 +89,15 @@
 already has dedicated seats in the steering committee (see section
 link:#steering-committee[steering committee]).
 
+If a non-Google seat on the steering committee becomes vacant before
+the current term ends, an exceptional election is conducted in order
+to replace the member(s) leaving the committee. The election will
+follow the same procedure as regular steering committee elections.
+The number of votes each maintainer gets in such exceptional election
+matches the number of seats to be filled. The term of the new member
+of the steering committee ends at the end of the current term of
+the steering committee when the next regular election concludes.
+
 [[contribution-process]]
 == Contribution Process
 
@@ -262,6 +271,15 @@
 It's also possible that the ESC decides that an issue is not a security issue
 and the embargo is lifted immediately.
 
+. Filing a CVE
++
+For every security issue a CVE that describes the issue and lists the affected
+releases should be filed. Filing a CVE can be done by any maintainer that works
+for an organization that can request CVE numbers (e.g. Googlers). The CVE
+number must be included in the release notes. The CVE itself is only made
+public after fixed released have been published and the embargo has been
+lifted.
+
 . Implementation of the security fix:
 +
 To keep the embargo intact, security fixes cannot be developed and reviewed in
@@ -325,6 +343,8 @@
 This ends the embargo and any issue that discusses the security vulnerability
 should be made public.
 
+. Publish the CVE
+
 . Follow-Up
 +
 The ESC should discuss if there are any learnings from the security
diff --git a/Documentation/dev-rest-api.txt b/Documentation/dev-rest-api.txt
index fec9c97..a28e230 100644
--- a/Documentation/dev-rest-api.txt
+++ b/Documentation/dev-rest-api.txt
@@ -50,6 +50,11 @@
  curl -X PUT --user john:2LlAB3K9B0PF --data-binary @project-desc.txt --header "Content-Type: application/json; charset=UTF-8" http://localhost:8080/a/projects/myproject/description
 ----
 
+[[pretty-json]]
+=== Pretty JSON
+
+By default any JSON in responses is compacted. To get pretty-printed JSON add `pp=1` to the request.
+
 === Authentication
 
 To test APIs that require authentication, the username and password must be specified on
diff --git a/Documentation/glossary.txt b/Documentation/glossary.txt
new file mode 100644
index 0000000..2b40b5b
--- /dev/null
+++ b/Documentation/glossary.txt
@@ -0,0 +1,50 @@
+:linkattrs:
+= Glossary
+
+[[event]]
+== Event
+
+It refers to the link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/java/com/google/gerrit/server/events/Event.java[com.google.gerrit.server.events.Event]
+base abstract class representing any possible action that is generated or
+received in a Gerrit instance. Actions can be associated with change set status
+updates, project creations, indexing of changes, etc.
+
+[[event-broker]]
+== Event broker
+
+Distributes Gerrit Events to listeners if they are allowed to see them.
+
+[[event-dispatcher]]
+== Event dispatcher
+
+Interface for posting events to the Gerrit event system. Implemented by default
+by link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/java/com/google/gerrit/server/events/EventBroker.java[com.google.gerrit.server.events.EventBroker].
+It can be implemented by plugins and allows to influence how events are managed.
+
+[[event-hierarchy]]
+== Event hierarchy
+
+Hierarchy of events representing anything that can happen in Gerrit.
+
+[[event-listener]]
+== Event listener
+
+API for listening to Gerrit events from plugins, without having any
+visibility restrictions.
+
+[[stream-events]]
+== Stream events
+
+Command that allows a user via CLI or a plugin to receive in a sequential way
+some events that are generated in Gerrit. The consumption of the stream by default
+is available via SSH connection.
+However, plugins can provide an alternative implementation of the event
+brokering by sending them over a reliable messaging queueing system (RabbitMQ)
+or a pub-sub (Kafka).
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/images/cross-repository-changes-add-topic.png b/Documentation/images/cross-repository-changes-add-topic.png
new file mode 100644
index 0000000..fc85b8f
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-add-topic.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-cp-menu.png b/Documentation/images/cross-repository-changes-cp-menu.png
new file mode 100644
index 0000000..e9004f8
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-cp-menu.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-cp-modal.png b/Documentation/images/cross-repository-changes-cp-modal.png
new file mode 100644
index 0000000..a4790fb
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-cp-modal.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-example.png b/Documentation/images/cross-repository-changes-example.png
new file mode 100644
index 0000000..e790f71
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-example.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-revert-topic-options.png b/Documentation/images/cross-repository-changes-revert-topic-options.png
new file mode 100644
index 0000000..f2e9f1a
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-revert-topic-options.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-revert-topic.png b/Documentation/images/cross-repository-changes-revert-topic.png
new file mode 100644
index 0000000..8d87191
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-revert-topic.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-submit-topic.png b/Documentation/images/cross-repository-changes-submit-topic.png
new file mode 100644
index 0000000..7e96743
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-submit-topic.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-submitted-together.png b/Documentation/images/cross-repository-changes-submitted-together.png
new file mode 100644
index 0000000..e7ea334
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-submitted-together.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-topic.png b/Documentation/images/cross-repository-changes-topic.png
new file mode 100644
index 0000000..12d0e38
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-topic.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-cannot-merge.png b/Documentation/images/gwt-user-review-ui-change-screen-change-info-cannot-merge.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-change-info-cannot-merge.png
rename to Documentation/images/gwt-user-review-ui-change-screen-change-info-cannot-merge.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info.png b/Documentation/images/gwt-user-review-ui-change-screen-change-info.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-change-info.png
rename to Documentation/images/gwt-user-review-ui-change-screen-change-info.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-update.png b/Documentation/images/gwt-user-review-ui-change-screen-change-update.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-change-update.png
rename to Documentation/images/gwt-user-review-ui-change-screen-change-update.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-commit-info-merge-commit.png b/Documentation/images/gwt-user-review-ui-change-screen-commit-info-merge-commit.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-commit-info-merge-commit.png
rename to Documentation/images/gwt-user-review-ui-change-screen-commit-info-merge-commit.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-commit-info.png b/Documentation/images/gwt-user-review-ui-change-screen-commit-info.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-commit-info.png
rename to Documentation/images/gwt-user-review-ui-change-screen-commit-info.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-history.png b/Documentation/images/gwt-user-review-ui-change-screen-history.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-history.png
rename to Documentation/images/gwt-user-review-ui-change-screen-history.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-included-in-list.png b/Documentation/images/gwt-user-review-ui-change-screen-included-in-list.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-included-in-list.png
rename to Documentation/images/gwt-user-review-ui-change-screen-included-in-list.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-inline-comments.png b/Documentation/images/gwt-user-review-ui-change-screen-inline-comments.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-inline-comments.png
rename to Documentation/images/gwt-user-review-ui-change-screen-inline-comments.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-not-current.png b/Documentation/images/gwt-user-review-ui-change-screen-not-current.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-not-current.png
rename to Documentation/images/gwt-user-review-ui-change-screen-not-current.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-permalink.png b/Documentation/images/gwt-user-review-ui-change-screen-permalink.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-permalink.png
rename to Documentation/images/gwt-user-review-ui-change-screen-permalink.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-plugin-extensions.png b/Documentation/images/gwt-user-review-ui-change-screen-plugin-extensions.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-plugin-extensions.png
rename to Documentation/images/gwt-user-review-ui-change-screen-plugin-extensions.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-reply-to-comment.png b/Documentation/images/gwt-user-review-ui-change-screen-reply-to-comment.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-reply-to-comment.png
rename to Documentation/images/gwt-user-review-ui-change-screen-reply-to-comment.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-reply.png b/Documentation/images/gwt-user-review-ui-change-screen-reply.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-reply.png
rename to Documentation/images/gwt-user-review-ui-change-screen-reply.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-replying.png b/Documentation/images/gwt-user-review-ui-change-screen-replying.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-replying.png
rename to Documentation/images/gwt-user-review-ui-change-screen-replying.png
Binary files differ
diff --git a/Documentation/images/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
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-comment-box.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-box.png
Binary files differ
diff --git a/Documentation/images/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
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-comment-edit.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-edit.png
Binary files differ
diff --git a/Documentation/images/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
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-comment-reply.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-reply.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-comment.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-comment.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-commented.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-commented.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-commented.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-commented.png
Binary files differ
diff --git a/Documentation/images/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
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
rename to Documentation/images/gwt-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-keyboard-shortcuts.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-navigation.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-navigation.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-navigation.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-navigation.png
Binary files differ
diff --git a/Documentation/images/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
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-no-differences.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-no-differences.png
Binary files differ
diff --git a/Documentation/images/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
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-patch-sets.png
rename to Documentation/images/gwt-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-project-and-file.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-project-and-file.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-rename.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-rename.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-rename.png
rename to Documentation/images/gwt-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/gwt-user-review-ui-side-by-side-diff-screen-replied-done.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-replied-done.png
rename to Documentation/images/gwt-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/gwt-user-review-ui-side-by-side-diff-screen.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen.png
Binary files differ
diff --git a/Documentation/images/inline-edit-enter-edit-mode-from-diff.png b/Documentation/images/inline-edit-enter-edit-mode-from-diff.png
deleted file mode 100644
index 46dd0ff..0000000
--- a/Documentation/images/inline-edit-enter-edit-mode-from-diff.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/inline-edit-enter-edit-mode-from-file-list.png b/Documentation/images/inline-edit-enter-edit-mode-from-file-list.png
deleted file mode 100644
index b8c52c9..0000000
--- a/Documentation/images/inline-edit-enter-edit-mode-from-file-list.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/inline-edit-file-list-in-edit-mode.png b/Documentation/images/inline-edit-file-list-in-edit-mode.png
deleted file mode 100644
index 8f355335..0000000
--- a/Documentation/images/inline-edit-file-list-in-edit-mode.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/inline-edit-full-screen-editor.png b/Documentation/images/inline-edit-full-screen-editor.png
deleted file mode 100644
index 474fae5..0000000
--- a/Documentation/images/inline-edit-full-screen-editor.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/intro-quick-hot-key-help.jpg b/Documentation/images/intro-quick-hot-key-help.jpg
deleted file mode 100644
index 41bcbe4..0000000
--- a/Documentation/images/intro-quick-hot-key-help.jpg
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-checks-overview.png b/Documentation/images/user-checks-overview.png
new file mode 100644
index 0000000..7a9864e
--- /dev/null
+++ b/Documentation/images/user-checks-overview.png
Binary files differ
diff --git a/Documentation/images/user-porting-comments-original-comment.png b/Documentation/images/user-porting-comments-original-comment.png
new file mode 100644
index 0000000..f8a62ee
--- /dev/null
+++ b/Documentation/images/user-porting-comments-original-comment.png
Binary files differ
diff --git a/Documentation/images/user-porting-comments-ported-comment.png b/Documentation/images/user-porting-comments-ported-comment.png
new file mode 100644
index 0000000..4e4a1b4
--- /dev/null
+++ b/Documentation/images/user-porting-comments-ported-comment.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-annotated.png b/Documentation/images/user-review-ui-change-screen-annotated.png
new file mode 100644
index 0000000..5c3f80a
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-annotated.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-actions.png b/Documentation/images/user-review-ui-change-screen-change-info-actions.png
index fd17d27..7c96861 100644
--- a/Documentation/images/user-review-ui-change-screen-change-info-actions.png
+++ b/Documentation/images/user-review-ui-change-screen-change-info-actions.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-labels.png b/Documentation/images/user-review-ui-change-screen-change-info-labels.png
index 2655089..61e2b25 100644
--- a/Documentation/images/user-review-ui-change-screen-change-info-labels.png
+++ b/Documentation/images/user-review-ui-change-screen-change-info-labels.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-last-update.png b/Documentation/images/user-review-ui-change-screen-change-info-last-update.png
deleted file mode 100644
index 93c296a..0000000
--- a/Documentation/images/user-review-ui-change-screen-change-info-last-update.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-owner.png b/Documentation/images/user-review-ui-change-screen-change-info-owner.png
deleted file mode 100644
index 3d73ef7..0000000
--- a/Documentation/images/user-review-ui-change-screen-change-info-owner.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-project-branch-topic.png b/Documentation/images/user-review-ui-change-screen-change-info-project-branch-topic.png
deleted file mode 100644
index acba408..0000000
--- a/Documentation/images/user-review-ui-change-screen-change-info-project-branch-topic.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-reviewers.png b/Documentation/images/user-review-ui-change-screen-change-info-reviewers.png
deleted file mode 100644
index 1f253ab..0000000
--- a/Documentation/images/user-review-ui-change-screen-change-info-reviewers.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-submit-strategy.png b/Documentation/images/user-review-ui-change-screen-change-info-submit-strategy.png
deleted file mode 100644
index abc1239..0000000
--- a/Documentation/images/user-review-ui-change-screen-change-info-submit-strategy.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-cherry-picks.png b/Documentation/images/user-review-ui-change-screen-cherry-picks.png
deleted file mode 100644
index 552d639..0000000
--- a/Documentation/images/user-review-ui-change-screen-cherry-picks.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-commit-message.png b/Documentation/images/user-review-ui-change-screen-commit-message.png
deleted file mode 100644
index acc78aa..0000000
--- a/Documentation/images/user-review-ui-change-screen-commit-message.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-conflicts-with.png b/Documentation/images/user-review-ui-change-screen-conflicts-with.png
deleted file mode 100644
index d74c934..0000000
--- a/Documentation/images/user-review-ui-change-screen-conflicts-with.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-download-commands-list.png b/Documentation/images/user-review-ui-change-screen-download-commands-list.png
deleted file mode 100644
index b12e1f0..0000000
--- a/Documentation/images/user-review-ui-change-screen-download-commands-list.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-download-commands.png b/Documentation/images/user-review-ui-change-screen-download-commands.png
index 6facd21..bccc61c 100644
--- a/Documentation/images/user-review-ui-change-screen-download-commands.png
+++ b/Documentation/images/user-review-ui-change-screen-download-commands.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list-comments.png b/Documentation/images/user-review-ui-change-screen-file-list-comments.png
deleted file mode 100644
index 9e8b6be..0000000
--- a/Documentation/images/user-review-ui-change-screen-file-list-comments.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list-header.png b/Documentation/images/user-review-ui-change-screen-file-list-header.png
deleted file mode 100644
index c682d88..0000000
--- a/Documentation/images/user-review-ui-change-screen-file-list-header.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list-mark-as-reviewed.png b/Documentation/images/user-review-ui-change-screen-file-list-mark-as-reviewed.png
deleted file mode 100644
index deb625d..0000000
--- a/Documentation/images/user-review-ui-change-screen-file-list-mark-as-reviewed.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list-modification-type.png b/Documentation/images/user-review-ui-change-screen-file-list-modification-type.png
deleted file mode 100644
index d8057b3..0000000
--- a/Documentation/images/user-review-ui-change-screen-file-list-modification-type.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list-rename.png b/Documentation/images/user-review-ui-change-screen-file-list-rename.png
deleted file mode 100644
index 364e6c5..0000000
--- a/Documentation/images/user-review-ui-change-screen-file-list-rename.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list-repeating-paths.png b/Documentation/images/user-review-ui-change-screen-file-list-repeating-paths.png
deleted file mode 100644
index f73d6bf..0000000
--- a/Documentation/images/user-review-ui-change-screen-file-list-repeating-paths.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list-size.png b/Documentation/images/user-review-ui-change-screen-file-list-size.png
deleted file mode 100644
index 4148b17..0000000
--- a/Documentation/images/user-review-ui-change-screen-file-list-size.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list.png b/Documentation/images/user-review-ui-change-screen-file-list.png
index 39c0e2b..721b229 100644
--- a/Documentation/images/user-review-ui-change-screen-file-list.png
+++ b/Documentation/images/user-review-ui-change-screen-file-list.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-included-in.png b/Documentation/images/user-review-ui-change-screen-included-in.png
index c2a900c..868368d 100644
--- a/Documentation/images/user-review-ui-change-screen-included-in.png
+++ b/Documentation/images/user-review-ui-change-screen-included-in.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-info-reviewers.png b/Documentation/images/user-review-ui-change-screen-info-reviewers.png
new file mode 100644
index 0000000..d79643e
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-info-reviewers.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png b/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
index 734ab29..9ef8f27 100644
--- a/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
+++ b/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-patch-set-list.png b/Documentation/images/user-review-ui-change-screen-patch-set-list.png
deleted file mode 100644
index ef03135..0000000
--- a/Documentation/images/user-review-ui-change-screen-patch-set-list.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-patch-sets.png b/Documentation/images/user-review-ui-change-screen-patch-sets.png
index 45b4089..79dd664 100644
--- a/Documentation/images/user-review-ui-change-screen-patch-sets.png
+++ b/Documentation/images/user-review-ui-change-screen-patch-sets.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-quick-approve.png b/Documentation/images/user-review-ui-change-screen-quick-approve.png
index 638fc2f..f692d07 100644
--- a/Documentation/images/user-review-ui-change-screen-quick-approve.png
+++ b/Documentation/images/user-review-ui-change-screen-quick-approve.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-related-changes-indicators.png b/Documentation/images/user-review-ui-change-screen-related-changes-indicators.png
deleted file mode 100644
index d0a997e..0000000
--- a/Documentation/images/user-review-ui-change-screen-related-changes-indicators.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-related-changes.png b/Documentation/images/user-review-ui-change-screen-related-changes.png
deleted file mode 100644
index 9d2d84c..0000000
--- a/Documentation/images/user-review-ui-change-screen-related-changes.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-same-topic.png b/Documentation/images/user-review-ui-change-screen-same-topic.png
deleted file mode 100644
index 18896ab..0000000
--- a/Documentation/images/user-review-ui-change-screen-same-topic.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-star.png b/Documentation/images/user-review-ui-change-screen-star.png
deleted file mode 100644
index d438ca0..0000000
--- a/Documentation/images/user-review-ui-change-screen-star.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-topleft.png b/Documentation/images/user-review-ui-change-screen-topleft.png
new file mode 100644
index 0000000..a1f7813
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-topleft.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen.png b/Documentation/images/user-review-ui-change-screen.png
index 8952cac..ff2570b 100644
--- a/Documentation/images/user-review-ui-change-screen.png
+++ b/Documentation/images/user-review-ui-change-screen.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-column.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-column.png
deleted file mode 100644
index b599f6d..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-column.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-dark-theme.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-dark-theme.png
deleted file mode 100644
index c041311..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-dark-theme.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png
index ea14a21..1eb7665 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-file-level-comment.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-file-level-comment.png
index 8406ce8..66c46b7 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-file-level-comment.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-file-level-comment.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-file-level-commented.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-file-level-commented.png
deleted file mode 100644
index 1fd2033..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-file-level-commented.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-intraline-difference.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-intraline-difference.png
deleted file mode 100644
index 044f96f..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-intraline-difference.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-preferences-popup.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-preferences-popup.png
deleted file mode 100644
index 043c1ff..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-preferences-popup.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-preferences.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-preferences.png
index 7373b2f..e008f2b 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-preferences.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-preferences.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-red-bar.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-red-bar.png
deleted file mode 100644
index f817d66..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-red-bar.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-reviewed.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-reviewed.png
index c767452..e2a7957 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-reviewed.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-reviewed.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-scrollbar.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-scrollbar.png
deleted file mode 100644
index cbadd26..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-scrollbar.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-search.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-search.png
deleted file mode 100644
index e69bb0d..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-search.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-syntax-coloring.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-syntax-coloring.png
deleted file mode 100644
index a4b019a..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-syntax-coloring.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 8f36ecc..dc94b14 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -38,6 +38,7 @@
 ... link:user-changeid.html[Change-Id Lines]
 ... link:user-signedoffby.html[Signed-off-by Lines]
 ... link:user-change-cleanup.html[Change Cleanup]
+... link:cross-repository-changes.html[Cross Repository Changes using Topics]
 
 == Project Management
 . link:project-configuration.html[Project Configuration]
@@ -74,10 +75,12 @@
 . link:config-reverseproxy.html[Reverse Proxy]
 . link:config-auto-site-initialization.html[Automatic Site Initialization on Startup]
 . link:pgm-index.html[Server Side Administrative Tools]
+. link:repository-maintenance.html[Repository Maintenance]
 . link:user-request-tracing.html[Request Tracing]
 . link:note-db.html[NoteDb]
 . link:config-accounts.html[Accounts on NoteDb]
 . link:config-groups.html[Groups on NoteDb]
+. link:user-privacy.html[User data and privacy]
 
 == Concepts
 . link:config-labels.html[Review Labels]
@@ -85,6 +88,7 @@
 . link:concept-changes.html[Changes]
 . link:concept-refs-for-namespace.html[The refs/for Namespace]
 . link:concept-patch-sets.html[Patch Sets]
+. link:glossary.html[Glossary]
 
 == Resources
 * link:licenses.html[Licenses and Notices]
diff --git a/Documentation/intro-gerrit-walkthrough-github.txt b/Documentation/intro-gerrit-walkthrough-github.txt
index f16155b..8f3ff88 100644
--- a/Documentation/intro-gerrit-walkthrough-github.txt
+++ b/Documentation/intro-gerrit-walkthrough-github.txt
@@ -206,8 +206,8 @@
 When you `git commit --amend` to iterate on your change, you might be worried that
 you are changing your previous commit and may thus lose that state of your work.
 However, here the Change-Id appended to your commit message comes into play.
-While the SHA1 hash of your change (the commit ID used by Git) might change, the
-Change-Id stays the same (in fact it is the SHA1 hash of the very first version
+While the SHA-1 hash of your change (the commit ID used by Git) might change, the
+Change-Id stays the same (in fact it is the SHA-1 hash of the very first version
 of that commit). When this amended commit is uploaded to the Gerrit server,
 Gerrit knows that this commit is really an iteration of that previous commit
 (and the associated review) and will preserve both, the old and the new state.
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index 7f932da..8a3b10e 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -634,9 +634,9 @@
 The Gerrit functionality can be extended by plugins and there are many
 extension points, e.g. plugins can
 +
-** link:dev-plugins.html#top-menu-extensions[add new menu entries]
-** link:dev-plugins.html#ui_extension[extend existing screens] and
-   link:dev-plugins.html#screen[add new screens]
+** link:pg-plugin-admin-api.html[add new menu entries]
+** link:pg-plugin-endpoints.html[hook into DOM elements] and
+   link:pg-plugin-dev.html#plugin-screen[add new pages]
 ** link:config-validation.html[do validation], e.g. of new commits
 ** add new REST endpoints and link:dev-plugins.html#ssh[SSH commands]
 
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 9909aac..dac1c6b 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -514,6 +514,9 @@
   $ git push origin HEAD:refs/heads/master -o topic=multi-master
 ----
 
+For more information about using topics, see the user guide:
+link:cross-repository-changes.html[Submitting Changes Across Repositories by using Topics].
+
 [[hashtags]]
 == Using Hashtags
 
diff --git a/Documentation/js-api.txt b/Documentation/js-api.txt
deleted file mode 100644
index 893ab36..0000000
--- a/Documentation/js-api.txt
+++ /dev/null
@@ -1,486 +0,0 @@
-= Gerrit Code Review - JavaScript API
-
-Gerrit Code Review supports an API for JavaScript plugins to interact
-with the web UI and the server process.
-
-== Entry Point
-
-JavaScript is loaded using a standard `<script src='...'>` HTML tag.
-Plugins should protect the global namespace by defining their code
-within an anonymous function passed to `Gerrit.install()`. The plugin
-will be passed an object describing its registration with Gerrit:
-
-[source,javascript]
-----
-Gerrit.install(function (self) {
-  // ... plugin JavaScript code here ...
-});
-----
-
-
-[[self]]
-== Plugin Instance
-
-The plugin instance is passed to the plugin's initialization function
-and provides a number of utility services to plugin authors.
-
-[[self_getServerInfo]]
-=== self.getServerInfo()
-Returns the server's link:rest-api-config.html#server-info[ServerInfo]
-data.
-
-[[self_getPluginName]]
-=== self.getPluginName()
-Returns the name this plugin was installed as by the server
-administrator. The plugin name is required to access REST API
-views installed by the plugin, or to access resources.
-
-[[self_on]]
-=== self.on()
-Register a JavaScript callback to be invoked when events occur within
-the web interface.
-
-.Signature
-[source,javascript]
-----
-self.on(event, callback);
-----
-
-* event: A supported event type. See below for description.
-
-* callback: JavaScript function to be invoked when event happens.
-  Arguments may be passed to this function, depending on the event.
-
-Supported events:
-
-* `history`: Invoked when the view is changed to a new screen within
-  the Gerrit web application.  The token after "#" is passed as the
-  argument to the callback function, for example "/c/42/" while
-  showing change 42.
-
-* `showchange`: Invoked when a change is made visible. A
-  link:rest-api-changes.html#change-info[ChangeInfo] and
-  link:rest-api-changes.html#revision-info[RevisionInfo]
-  are passed as arguments. PolyGerrit provides a third parameter which
-  is an object with a `mergeable` boolean.
-
-* `submitchange`: Invoked when the submit button is clicked
-  on a change. A link:rest-api-changes.html#change-info[ChangeInfo]
-  and link:rest-api-changes.html#revision-info[RevisionInfo] are
-  passed as arguments. Similar to a form submit validation, the
-  function must return true to allow the operation to continue, or
-  false to prevent it. The function may be called multiple times, for
-  example, if submitting a change shows a confirmation dialog, this
-  event may be called to validate that the check whether dialog can be
-  shown, and called again when the submit is confirmed to check whether
-  the actual submission action can proceed.
-
-* `comment`: Invoked when a DOM element that represents a comment is
-  created. This DOM element is passed as argument. This DOM element
-  contains nested elements that Gerrit uses to format the comment. The
-  DOM structure may differ between comment types such as inline
-  comments, file-level comments and summary comments, and it may change
-  with new Gerrit versions.
-
-* `highlightjs-loaded`: Invoked when the highlight.js library has
-  finished loading. The global `hljs` object (also now accessible via
-  `window.hljs`) is passed as an argument to the callback function.
-  This event can be used to register a new language highlighter with
-  the highlight.js library before syntax highlighting begins.
-
-[[self_changeActions]]
-=== self.changeActions()
-Returns an instance of ChangeActions API.
-
-.Signature
-[source,javascript]
-----
-self.changeActions();
-----
-
-[[self_screen]]
-=== self.screen()
-Register a module to be attached when the user navigates
-to an extension screen provided by the plugin. Extension screens are
-usually linked from the link:dev-plugins.html#top-menu-extensions[top menu].
-
-.Signature
-[source,javascript]
-----
-self.screen(pattern, opt_moduleName);
-----
-
-* pattern: URL token pattern to identify the screen. Argument can be
-  either a string (`'index'`) or a RegExp object (`/list\/(.*)/`).
-  If a RegExp is used the matching groups will be available inside of
-  the context as `token_match`.
-
-* opt_moduleName: The module to load when the user navigates to
-  the screen. The function will be passed a link:#ScreenContext[screen context].
-
-[[self_settings]]
-=== self.settings()
-Returns the Settings API.
-
-.Signature
-[source,javascript]
-----
-self.settings();
-----
-
-[[self_registerCustomComponent]]
-=== self.registerCustomComponent()
-Register a custom component to a specific endpoint.
-
-.Signature
-[source,javascript]
-----
-self.registerCustomComponent(endpointName, opt_moduleName, opt_options);
-----
-
-* endpointName: The endpoint this plugin should be reigistered to.
-
-* opt_moduleName: The module name the custom component will use.
-
-* opt_options: Options to register this custom component.
-
-[[self_url]]
-=== self.url()
-Returns a URL within the plugin's URL space. If invoked with no
-parameter the URL of the plugin is returned. If passed a string
-the argument is appended to the plugin URL.
-
-A plugin's URL is where this plugin is loaded, it doesn't
-necessary to be the same as the Gerrit host. Use `window.location`
-if you need to access the Gerrit host info.
-
-For preloaded plugins, the plugin url is based on a global
-configuration of where to load all plugins, default to current host.
-
-[source,javascript]
-----
-self.url();                    // "https://gerrit-review.googlesource.com/plugins/demo/"
-self.url('/static/icon.png');  // "https://gerrit-review.googlesource.com/plugins/demo/static/icon.png"
-----
-
-[[self_restApi]]
-=== self.restApi()
-Returns an instance of the Plugin REST API.
-
-.Signature
-[source,javascript]
-----
-self.restApi(prefix_url)
-----
-
-* prefix_url: Base url for subsequent .get(), .post() etc requests.
-
-[[PluginRestAPI]]
-== Plugin Rest API
-
-[[plugin_rest_delete]]
-=== restApi.delete()
-Issues a DELETE REST API request to the Gerrit server.
-Returns a promise with the response of the request.
-
-.Signature
-[source,javascript]
-----
-restApi.delete(url)
-----
-
-* url: URL relative to the base url.
-
-[[plugin_rest_get]]
-=== restApi.get()
-Issues a GET REST API request to the Gerrit server.
-Returns a promise with the response of the request.
-
-.Signature
-[source,javascript]
-----
-restApi.get(url)
-----
-
-* url: URL relative to the base url.
-
-[[plugin_rest_post]]
-=== restApi.post()
-Issues a POST REST API request to the Gerrit server.
-Returns a promise with the response of the request.
-
-.Signature
-[source,javascript]
-----
-restApi.post(url, opt_payload, opt_errFn, opt_contentType)
-----
-
-* url: URL relative to the base url.
-
-* opt_payload: JavaScript object to serialize as the request payload.
-
-* opt_errFn: JavaScript function to be invoked when error occured.
-
-* opt_contentType: Content-Type to be sent along with the request.
-
-[source,javascript]
-----
-restApi.post(
-  '/my-servlet',
-  {start_build: true, platform_type: 'Linux'});
-----
-
-[[plugin_rest_put]]
-=== restApi.put()
-Issues a PUT REST API request to the Gerrit server.
-Returns a promise with the response of the request.
-
-.Signature
-[source,javascript]
-----
-restApi.put(url, opt_payload, opt_errFn, opt_contentType)
-----
-
-* url: URL relative to the base url.
-
-* opt_payload: JavaScript object to serialize as the request payload.
-
-* opt_errFn: JavaScript function to be invoked when error occured.
-
-* opt_contentType: Content-Type to be sent along with the request.
-
-[source,javascript]
-----
-restApi.put(
-  '/builds',
-  {start_build: true, platform_type: 'Linux'});
-----
-
-[[ChangeActions]]
-== Change Actions API
-A new Change Actions API instance will be created when `changeActions()`
-is invoked.
-
-[[change_actions_add]]
-=== changeActions.add()
-Adds a new action to the change actions section.
-Returns the key of the newly added action.
-
-.Signature
-[source,javascript]
-----
-changeActions.add(type, label)
-----
-
-* type: The type of the action, either `change` or `revision`.
-
-* label: The label to be used in UI for this action.
-
-[source,javascript]
-----
-changeActions.add("change", "test")
-----
-
-[[change_actions_remove]]
-=== changeActions.remove()
-Removes an action from the change actions section.
-
-.Signature
-[source,javascript]
-----
-changeActions.remove(key)
-----
-
-* key: The key of the action.
-
-[[change_actions_addTapListener]]
-=== changeActions.addTapListener()
-Adds a tap listener to an action that will be invoked when the action
-is tapped.
-
-.Signature
-[source,javascript]
-----
-changeActions.addTapListener(key, callback)
-----
-
-* key: The key of the action.
-
-* callback: JavaScript function to be invoked when action tapped.
-
-[source,javascript]
-----
-changeActions.addTapListener("__key_for_my_action__", () => {
-  // do something when my action gets clicked
-})
-----
-
-[[change_actions_removeTapListener]]
-=== changeActions.removeTapListener()
-Removes an existing tap listener on an action.
-
-.Signature
-[source,javascript]
-----
-changeActions.removeTapListener(key, callback)
-----
-
-* key: The key of the action.
-
-* callback: JavaScript function to be removed.
-
-[[change_actions_setLabel]]
-=== changeActions.setLabel()
-Sets the label for an action.
-
-.Signature
-[source,javascript]
-----
-changeActions.setLabel(key, label)
-----
-
-* key: The key of the action.
-
-* label: The label of the action.
-
-[[change_actions_setTitle]]
-=== changeActions.setTitle()
-Sets the title for an action.
-
-.Signature
-[source,javascript]
-----
-changeActions.setTitle(key, title)
-----
-
-* key: The key of the action.
-
-* title: The title of the action.
-
-[[change_actions_setIcon]]
-=== changeActions.setIcon()
-Sets an icon for an action.
-
-.Signature
-[source,javascript]
-----
-changeActions.setIcon(key, icon)
-----
-
-* key: The key of the action.
-
-* icon: The name of the icon.
-
-[[change_actions_setEnabled]]
-=== changeActions.setEnabled()
-Sets an action to enabled or disabled.
-
-.Signature
-[source,javascript]
-----
-changeActions.setEnabled(key, enabled)
-----
-
-* key: The key of the action.
-
-* enabled: The status of the action, true to enable.
-
-[[change_actions_setActionHidden]]
-=== changeActions.setActionHidden()
-Sets an action to be hidden.
-
-.Signature
-[source,javascript]
-----
-changeActions.setActionHidden(type, key, hidden)
-----
-
-* type: The type of the action.
-
-* key: The key of the action.
-
-* hidden: True to hide the action, false to show the action.
-
-[[change_actions_setActionOverflow]]
-=== changeActions.setActionOverflow()
-Sets an action to show in overflow menu.
-
-.Signature
-[source,javascript]
-----
-changeActions.setActionOverflow(type, key, overflow)
-----
-
-* type: The type of the action.
-
-* key: The key of the action.
-
-* overflow: True to move the action to overflow menu, false to move
-  the action out of the overflow menu.
-
-[[PanelContext]]
-== Panel Context
-A new panel context is passed to the `panel` callback function each
-time a screen with the given extension point is loaded.
-
-[[panel_body]]
-=== panel.body
-Empty HTML `<div>` node the plugin should add the panel content to.
-The node is already attached to the document.
-
-[[PanelProperties]]
-=== Properties
-
-The extension panel parameters that are described in the
-link:dev-plugins.html#panels[plugin development documentation] are
-contained in the context as properties. Which properties are available
-depends on the extension point.
-
-[[Gerrit]]
-== Gerrit
-
-The `Gerrit` object is the only symbol provided into the global
-namespace by Gerrit Code Review. All top-level functions can be
-accessed through this name.
-
-[[Gerrit_css]]
-=== Gerrit.css()
-[WARNING]
-This method is deprecated. It doesn't work with Shadow DOM and
-will be removed in the future. Please, use link:pg-plugin-dev.html#plugin-styles[plugin.styles] instead.
-
-Creates a new unique CSS class and injects it into the document.
-The name of the class is returned and can be used by the plugin.
-
-Classes created with this function should be created once at install
-time and reused throughout the plugin.  Repeatedly creating the same
-class will explode the global stylesheet.
-
-.Signature
-[source,javascript]
-----
-Gerrit.install(function(self)) {
-  var style = {
-    name: Gerrit.css('background: #fff; color: #000;'),
-  };
-});
-----
-
-[[Gerrit_install]]
-=== Gerrit.install()
-Registers a new plugin by invoking the supplied initialization
-function. The function is passed the link:#self[plugin instance].
-
-[source,javascript]
-----
-Gerrit.install(function (self) {
-  // ... plugin JavaScript code here ...
-});
-----
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index ae494dd..e611ff8 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -247,6 +247,38 @@
 ----
 
 
+[[DefinitelyTyped]]
+DefinitelyTyped
+
+* @types/resize-observer-browser
+
+[[DefinitelyTyped_license]]
+----
+    MIT License
+
+    Copyright (c) Microsoft Corporation.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in all
+    copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+    SOFTWARE
+
+----
+
+
 [[Polymer-2014]]
 Polymer-2014
 
@@ -330,15 +362,19 @@
 * @polymer/neon-animation
 * @polymer/paper-behaviors
 * @polymer/paper-button
+* @polymer/paper-card
 * @polymer/paper-dialog
 * @polymer/paper-dialog-behavior
 * @polymer/paper-dialog-scrollable
+* @polymer/paper-dropdown-menu
 * @polymer/paper-icon-button
 * @polymer/paper-input
 * @polymer/paper-item
 * @polymer/paper-listbox
+* @polymer/paper-menu-button
 * @polymer/paper-tabs
 * @polymer/paper-toggle-button
+* @polymer/paper-tooltip
 
 [[Polymer-2015_license]]
 ----
@@ -380,6 +416,52 @@
 ----
 
 
+[[Polymer-2016]]
+Polymer-2016
+
+* @polymer/iron-image
+* @polymer/paper-checkbox
+
+[[Polymer-2016_license]]
+----
+Copyright (c) 2016 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
 [[Polymer-2017]]
 Polymer-2017
 
@@ -992,6 +1074,84 @@
 ----
 
 
+[[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
 
@@ -1056,3 +1216,239 @@
 
 ----
 
+
+[[rxjs]]
+rxjs
+
+* rxjs
+
+[[rxjs_license]]
+----
+                               Apache License
+                         Version 2.0, January 2004
+                      http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+    "License" shall mean the terms and conditions for use, reproduction,
+    and distribution as defined by Sections 1 through 9 of this document.
+
+    "Licensor" shall mean the copyright owner or entity authorized by
+    the copyright owner that is granting the License.
+
+    "Legal Entity" shall mean the union of the acting entity and all
+    other entities that control, are controlled by, or are under common
+    control with that entity. For the purposes of this definition,
+    "control" means (i) the power, direct or indirect, to cause the
+    direction or management of such entity, whether by contract or
+    otherwise, or (ii) ownership of fifty percent (50%) or more of the
+    outstanding shares, or (iii) beneficial ownership of such entity.
+
+    "You" (or "Your") shall mean an individual or Legal Entity
+    exercising permissions granted by this License.
+
+    "Source" form shall mean the preferred form for making modifications,
+    including but not limited to software source code, documentation
+    source, and configuration files.
+
+    "Object" form shall mean any form resulting from mechanical
+    transformation or translation of a Source form, including but
+    not limited to compiled object code, generated documentation,
+    and conversions to other media types.
+
+    "Work" shall mean the work of authorship, whether in Source or
+    Object form, made available under the License, as indicated by a
+    copyright notice that is included in or attached to the work
+    (an example is provided in the Appendix below).
+
+    "Derivative Works" shall mean any work, whether in Source or Object
+    form, that is based on (or derived from) the Work and for which the
+    editorial revisions, annotations, elaborations, or other modifications
+    represent, as a whole, an original work of authorship. For the purposes
+    of this License, Derivative Works shall not include works that remain
+    separable from, or merely link (or bind by name) to the interfaces of,
+    the Work and Derivative Works thereof.
+
+    "Contribution" shall mean any work of authorship, including
+    the original version of the Work and any modifications or additions
+    to that Work or Derivative Works thereof, that is intentionally
+    submitted to Licensor for inclusion in the Work by the copyright owner
+    or by an individual or Legal Entity authorized to submit on behalf of
+    the copyright owner. For the purposes of this definition, "submitted"
+    means any form of electronic, verbal, or written communication sent
+    to the Licensor or its representatives, including but not limited to
+    communication on electronic mailing lists, source code control systems,
+    and issue tracking systems that are managed by, or on behalf of, the
+    Licensor for the purpose of discussing and improving the Work, but
+    excluding communication that is conspicuously marked or otherwise
+    designated in writing by the copyright owner as "Not a Contribution."
+
+    "Contributor" shall mean Licensor and any individual or Legal Entity
+    on behalf of whom a Contribution has been received by Licensor and
+    subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+    this License, each Contributor hereby grants to You a perpetual,
+    worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+    copyright license to reproduce, prepare Derivative Works of,
+    publicly display, publicly perform, sublicense, and distribute the
+    Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+    this License, each Contributor hereby grants to You a perpetual,
+    worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+    (except as stated in this section) patent license to make, have made,
+    use, offer to sell, sell, import, and otherwise transfer the Work,
+    where such license applies only to those patent claims licensable
+    by such Contributor that are necessarily infringed by their
+    Contribution(s) alone or by combination of their Contribution(s)
+    with the Work to which such Contribution(s) was submitted. If You
+    institute patent litigation against any entity (including a
+    cross-claim or counterclaim in a lawsuit) alleging that the Work
+    or a Contribution incorporated within the Work constitutes direct
+    or contributory patent infringement, then any patent licenses
+    granted to You under this License for that Work shall terminate
+    as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+    Work or Derivative Works thereof in any medium, with or without
+    modifications, and in Source or Object form, provided that You
+    meet the following conditions:
+
+    (a) You must give any other recipients of the Work or
+        Derivative Works a copy of this License; and
+
+    (b) You must cause any modified files to carry prominent notices
+        stating that You changed the files; and
+
+    (c) You must retain, in the Source form of any Derivative Works
+        that You distribute, all copyright, patent, trademark, and
+        attribution notices from the Source form of the Work,
+        excluding those notices that do not pertain to any part of
+        the Derivative Works; and
+
+    (d) If the Work includes a "NOTICE" text file as part of its
+        distribution, then any Derivative Works that You distribute must
+        include a readable copy of the attribution notices contained
+        within such NOTICE file, excluding those notices that do not
+        pertain to any part of the Derivative Works, in at least one
+        of the following places: within a NOTICE text file distributed
+        as part of the Derivative Works; within the Source form or
+        documentation, if provided along with the Derivative Works; or,
+        within a display generated by the Derivative Works, if and
+        wherever such third-party notices normally appear. The contents
+        of the NOTICE file are for informational purposes only and
+        do not modify the License. You may add Your own attribution
+        notices within Derivative Works that You distribute, alongside
+        or as an addendum to the NOTICE text from the Work, provided
+        that such additional attribution notices cannot be construed
+        as modifying the License.
+
+    You may add Your own copyright statement to Your modifications and
+    may provide additional or different license terms and conditions
+    for use, reproduction, or distribution of Your modifications, or
+    for any such Derivative Works as a whole, provided Your use,
+    reproduction, and distribution of the Work otherwise complies with
+    the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+    any Contribution intentionally submitted for inclusion in the Work
+    by You to the Licensor shall be under the terms and conditions of
+    this License, without any additional terms or conditions.
+    Notwithstanding the above, nothing herein shall supersede or modify
+    the terms of any separate license agreement you may have executed
+    with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+    names, trademarks, service marks, or product names of the Licensor,
+    except as required for reasonable and customary use in describing the
+    origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+    agreed to in writing, Licensor provides the Work (and each
+    Contributor provides its Contributions) on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+    implied, including, without limitation, any warranties or conditions
+    of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+    PARTICULAR PURPOSE. You are solely responsible for determining the
+    appropriateness of using or redistributing the Work and assume any
+    risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+    whether in tort (including negligence), contract, or otherwise,
+    unless required by applicable law (such as deliberate and grossly
+    negligent acts) or agreed to in writing, shall any Contributor be
+    liable to You for damages, including any direct, indirect, special,
+    incidental, or consequential damages of any character arising as a
+    result of this License or out of the use or inability to use the
+    Work (including but not limited to damages for loss of goodwill,
+    work stoppage, computer failure or malfunction, or any and all
+    other commercial damages or losses), even if such Contributor
+    has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+    the Work or Derivative Works thereof, You may choose to offer,
+    and charge a fee for, acceptance of support, warranty, indemnity,
+    or other liability obligations and/or rights consistent with this
+    License. However, in accepting such obligations, You may act only
+    on Your own behalf and on Your sole responsibility, not on behalf
+    of any other Contributor, and only if You agree to indemnify,
+    defend, and hold each Contributor harmless for any liability
+    incurred by, or claims asserted against, such Contributor by reason
+    of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+    To apply the Apache License to your work, attach the following
+    boilerplate notice, with the fields enclosed by brackets "[]"
+    replaced with your own identifying information. (Don't include
+    the brackets!)  The text should be enclosed in the appropriate
+    comment syntax for the file format. We also recommend that a
+    file or class name and description of purpose be included on the
+    same "printed page" as the copyright notice for easier
+    identification within third-party archives.
+
+ Copyright (c) 2015-2018 Google, Inc., Netflix, Inc., Microsoft Corp. and contributors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ 
+
+----
+
+
+[[tslib]]
+tslib
+
+* tslib
+
+[[tslib_license]]
+----
+Copyright (c) Microsoft Corporation.

+

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

+purpose with or without fee is hereby granted.

+

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

+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY

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

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

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

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

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

+

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

+purpose with or without fee is hereby granted.

+

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

+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY

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

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

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

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

+PERFORMANCE OF THIS SOFTWARE.
+
+----
+
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/logs.txt b/Documentation/logs.txt
index f072984..e120fdd 100644
--- a/Documentation/logs.txt
+++ b/Documentation/logs.txt
@@ -51,6 +51,11 @@
 * `referer`: the `Referer` HTTP request header. This gives the site that
   the client reports having been referred from.
 * `client agent`: the client agent which sent the request.
+* `total_cpu`: total CPU time, CPU time in milliseconds to execute command.
+* `user_cpu`: user mode CPU time, CPU time in user mode in milliseconds to execute command.
+  CPU time in kernel mode is `total_cpu - user_cpu`.
+* `memory`: memory allocated in bytes to execute command. -1 if the JVM does
+  not support this metric.
 * `command status`: the overall result of the git command over HTTP. Currently
    populated only for the transfer phase of `git-upload-pack` commands.
    Possible values:
@@ -89,6 +94,11 @@
 * `wait`: command wait time, time in milliseconds the command waited for an execution thread.
 * `exec`: command execution time, time in milliseconds to execute the command.
 * `status`: status code. 0 means success, any other value is an error.
+* `total_cpu`: total CPU time, CPU time in milliseconds to execute command.
+* `user_cpu`: user mode CPU time, CPU time in user mode in milliseconds to execute command.
+  CPU time in kernel mode is `total_cpu - user_cpu`.
+* `memory`: memory allocated in bytes to execute command. -1 if the JVM does
+  not support this metric.
 
 The `git-upload-pack` command provides the following additional fields after the `exec`
 and before the `status` field. All times are in milliseconds. Fields are -1 if not available
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 74ebe144..7ac804c 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -50,6 +50,9 @@
 objects needing finalization.
 * `proc/jvm/gc/count`: Number of GCs.
 * `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.
 * `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.
@@ -76,6 +79,12 @@
 * `change/submit_rule_evaluation`: Latency for evaluating submit rules on a change.
 * `change/submit_type_evaluation`: Latency for evaluating the submit type on a change.
 
+=== Comments
+
+* `ported_comments/as_patchset_level`: Total number of comments ported as patchset-level comments.
+* `ported_comments/as_file_level`: Total number of comments ported as file-level comments.
+* `ported_comments/as_range_comments`: Total number of comments having line/range values in the ported patchset.
+
 === HTTP
 
 ==== Jetty
@@ -187,6 +196,8 @@
 * `git/upload-pack/phase_compressing`: Time spent in the 'Compressing...' phase.
 * `git/upload-pack/phase_writing`: Time spent transferring bytes to client.
 * `git/upload-pack/pack_bytes`: Distribution of sizes of packs sent to clients.
+* `git/auto-merge/num_operations`: Number of auto merge operations and context.
+* `git/auto-merge/latency`: Latency of auto merge operations and context.
 
 === BatchUpdate
 
diff --git a/Documentation/pg-plugin-admin-api.txt b/Documentation/pg-plugin-admin-api.txt
index 1a41778..be31117 100644
--- a/Documentation/pg-plugin-admin-api.txt
+++ b/Documentation/pg-plugin-admin-api.txt
@@ -1,4 +1,4 @@
-= Gerrit Code Review - Admin customization API
+= Gerrit Code Review - JavaScript Plugin Admin API
 
 This API is provided by link:pg-plugin-dev.html#plugin-admin[plugin.admin()]
 and provides customization of the admin menu.
@@ -18,4 +18,4 @@
 create a link to open changes, use the value `/q/status:open`.
 
 See more about capability from
-link:rest-api-accounts.html#list-account-capabilities[List Account Capabilities].
\ No newline at end of file
+link:rest-api-accounts.html#list-account-capabilities[List Account Capabilities].
diff --git a/Documentation/pg-plugin-change-metadata-api.txt b/Documentation/pg-plugin-change-metadata-api.txt
deleted file mode 100644
index 8348da8..0000000
--- a/Documentation/pg-plugin-change-metadata-api.txt
+++ /dev/null
@@ -1,16 +0,0 @@
-= Gerrit Code Review - Change metadata plugin API
-
-This API is provided by
-link:pg-plugin-dev.html#change-metadata[plugin.changeMetadata()] and provides
-interface for customization and data updates of change metadata.
-
-== onLabelsChanged
-`changeMetadataApi.onLabelsChanged(callback)`
-
-.Params
-- *callback* function that's executed when labels changed on the server.
-Callback receives labels with scores applied to the change, map of the label
-names to link:rest-api-changes.html#label-info[LabelInfo] entries
-
-.Returns
-- `GrChangeMetadataApi` for chaining.
diff --git a/Documentation/pg-plugin-checks-api.txt b/Documentation/pg-plugin-checks-api.txt
new file mode 100644
index 0000000..4e93da1
--- /dev/null
+++ b/Documentation/pg-plugin-checks-api.txt
@@ -0,0 +1,44 @@
+:linkattrs:
+= Gerrit Code Review - JavaScript Plugin Checks API
+
+This API is provided by link:pg-plugin-dev.html#plugin-checks[plugin.checks()].
+It allows plugins to contribute to the "Checks" tab and summary:
+
+image::images/user-checks-overview.png[width=800]
+
+Each plugin can link:#register[register] a checks provider that will be called
+when a change page is loaded. Such a call would return a list of `Runs` and each
+run can contain a list of `Results`.
+
+The details of the ChecksApi are documented in the
+link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/api/checks.ts[source code].
+Note that this link points to the `master` branch and might thus reflect a
+newer version of the API than your Gerrit installation.
+
+If no plugins are registered with the ChecksApi, then the Checks tab will be
+hidden.
+
+You can read about the motivation, the use cases and the original plans in the
+link:https://www.gerritcodereview.com/design-docs/ci-reboot.html[design doc].
+
+Here are some examples of open source plugins that make use of the Checks API:
+
+* link:https://gerrit.googlesource.com/plugins/checks/+/master/gr-checks/plugin.js[Gerrit Checks Plugin]
+* link:https://chromium.googlesource.com/infra/gerrit-plugins/buildbucket/+/master/src/main/resources/static/buildbucket.js[Chromium Buildbucket Plugin]
+* link:https://chromium.googlesource.com/infra/gerrit-plugins/code-coverage/+/master/src/main/resources/static/chromium-coverage.js[Chromium Coverage Plugin]
+
+[[register]]
+== register
+`checksApi.register(provider, config?)`
+
+.Params
+- *provider* Must implement a `fetch()` interface that returns a
+  `Promise<FetchResponse>` with runs and results. See also documentation in the
+  link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/api/checks.ts[source code].
+- *config* Optional configuration values for the checks provider.
+
+[[announceUpdate]]
+== announceUpdate
+`checksApi.announceUpdate()`
+
+Tells Gerrit to call `provider.fetch()`.
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index 91bc476..dc7986f 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -1,39 +1,39 @@
 :linkattrs:
-= Gerrit Code Review - PolyGerrit Plugin Development
+= Gerrit Code Review - JavaScript Plugin Development and API
 
-CAUTION: Work in progress. Hard hat area. Please
-link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20plugins[send
-feedback,role=external,window=_blank] if something's not right.
-
-For migrating existing GWT UI plugins, please check out the
-link:pg-plugin-migration.html#migration[migration guide].
+Gerrit Code Review supports an API for JavaScript plugins to interact
+with the web UI and the server process.
 
 [[loading]]
 == Plugin loading and initialization
 
-link:js-api.html#_entry_point[Entry point] for the plugin.
+JavaScript is loaded using a standard `<script src='...'>` HTML tag.
+Plugins should protect the global namespace by defining their code
+within an anonymous function passed to `Gerrit.install()`. The plugin
+will be passed an object describing its registration with Gerrit.
 
 * The plugin provides pluginname.js, and can be a standalone file or a static
   asset in a jar as a link:dev-plugins.html#deployment[Web UI plugin].
 * pluginname.js contains a call to `Gerrit.install()`. There should
-  only be single `Gerrit.install()` per file.
-* PolyGerrit imports pluginname.js.
+  only be a single `Gerrit.install()` call per file.
+* The Gerrit web app imports pluginname.js.
 * For standalone plugins, the entry point file is a `pluginname.js` file
   located in `gerrit-site/plugins` folder, where `pluginname` is an alphanumeric
   plugin name.
 
-Note: Code examples target modern browsers (Chrome, Firefox, Safari, Edge).
-
+=== Examples
 Here's a recommended starter `myplugin.js`:
 
 ``` js
 Gerrit.install(plugin => {
-  'use strict';
-
   // Your code here.
 });
 ```
 
+You can find more elaborate examples in the
+link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples/[polygerrit-ui/app/samples/]
+directory of the source tree.
+
 [[low-level-api-concepts]]
 == Low-level DOM API concepts
 
@@ -41,7 +41,7 @@
 decorating, replacing, and styling DOM elements exposed through a set of
 link:pg-plugin-endpoints.html[endpoints].
 
-PolyGerrit provides a simple way for accessing the DOM via DOM hooks API. A DOM
+Gerrit provides a simple way for accessing the DOM via DOM hooks API. A DOM
 hook is a custom element that is instantiated for the plugin endpoint. In the
 decoration case, a hook is set with a `content` attribute that points to the DOM
 element.
@@ -64,7 +64,7 @@
 [[low-level-decorating]]
 === Decorating DOM Elements
 
-For each endpoint, PolyGerrit provides a list of DOM properties (such as
+For each endpoint, Gerrit provides a list of DOM properties (such as
 attributes and events) that are supported in the long-term.
 
 ``` js
@@ -101,9 +101,9 @@
 `plugin.registerStyleModule(endpointName, moduleName)`. A style must be defined
 as a standalone `<dom-module>` defined in the same .js file.
 
-See `samples/theme-plugin.js` for examples.
-
-Note: TODO: Insert link to the full styling API.
+See
+link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples/theme-plugin.js[samples/theme-plugin.js]
+for an example.
 
 ``` js
 const styleElement = document.createElement('dom-module');
@@ -146,45 +146,87 @@
 binding,role=external,window=_blank] for plugins that don't use Polymer. Can be used to bind element
 attribute changes to callbacks.
 
-See `samples/bind-parameters.js` for examples on both Polymer data bindings
-and `attibuteHelper` usage.
-
-=== eventHelper
-`plugin.eventHelper(element)`
-
-Note: TODO
+See
+link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples/bind-parameters.js[samples/bind-parameters.js]
+for an example.
 
 === hook
 `plugin.hook(endpointName, opt_options)`
 
-See list of supported link:pg-plugin-endpoints.html[endpoints].
-
-Note: TODO
+See link:pg-plugin-endpoints.html[endpoints].
 
 === registerCustomComponent
 `plugin.registerCustomComponent(endpointName, opt_moduleName, opt_options)`
 
-See list of supported link:pg-plugin-endpoints.html[endpoints].
-
-Note: TODO
+See link:pg-plugin-endpoints.html[endpoints].
 
 === registerDynamicCustomComponent
 `plugin.registerDynamicCustomComponent(dynamicEndpointName, opt_moduleName,
 opt_options)`
 
-See list of supported link:pg-plugin-endpoints.html[endpoints].
-
-Note: TODO
+See link:pg-plugin-endpoints.html[endpoints].
 
 === registerStyleModule
 `plugin.registerStyleModule(endpointName, moduleName)`
 
-Note: TODO
+See link:#low-level-style[above].
+
+=== on
+Register a JavaScript callback to be invoked when events occur within
+the web interface. Signature
+
+``` js
+self.on(event, callback);
+```
+
+Parameters
+
+* event: A supported event type. See below for description.
+
+* callback: JavaScript function to be invoked when event happens.
+  Arguments may be passed to this function, depending on the event.
+
+Supported events:
+
+* `history`: Invoked when the view is changed to a new screen within
+  the Gerrit web application.  The token after "#" is passed as the
+  argument to the callback function, for example "/c/42/" while
+  showing change 42.
+
+* `showchange`: Invoked when a change is made visible. A
+  link:rest-api-changes.html#change-info[ChangeInfo] and
+  link:rest-api-changes.html#revision-info[RevisionInfo]
+  are passed as arguments. Gerrit provides a third parameter which
+  is an object with a `mergeable` boolean.
+
+* `submitchange`: Invoked when the submit button is clicked
+  on a change. A link:rest-api-changes.html#change-info[ChangeInfo]
+  and link:rest-api-changes.html#revision-info[RevisionInfo] are
+  passed as arguments. Similar to a form submit validation, the
+  function must return true to allow the operation to continue, or
+  false to prevent it. The function may be called multiple times, for
+  example, if submitting a change shows a confirmation dialog, this
+  event may be called to validate that the check whether dialog can be
+  shown, and called again when the submit is confirmed to check whether
+  the actual submission action can proceed.
+
+* `comment`: Invoked when a DOM element that represents a comment is
+  created. This DOM element is passed as argument. This DOM element
+  contains nested elements that Gerrit uses to format the comment. The
+  DOM structure may differ between comment types such as inline
+  comments, file-level comments and summary comments, and it may change
+  with new Gerrit versions.
+
+* `highlightjs-loaded`: Invoked when the highlight.js library has
+  finished loading. The global `hljs` object (also now accessible via
+  `window.hljs`) is passed as an argument to the callback function.
+  This event can be used to register a new language highlighter with
+  the highlight.js library before syntax highlighting begins.
 
 [[high-level-api]]
 == High-level API
 
-Plugin instance provides access to number of more specific APIs and methods
+Plugin instance provides access to a number of more specific APIs and methods
 to be used by plugin authors.
 
 === admin
@@ -196,95 +238,167 @@
 .Returns:
 - Instance of link:pg-plugin-admin-api.html[GrAdminApi].
 
+=== changeActions
+`self.changeActions()`
+
+Returns an instance of the
+link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/api/change-actions.ts[ChangeActionsPluginApi].
+
+==== changeActions.add()
+Adds a new action to the change actions section. Returns the key of the newly
+added action.
+
+``` js
+changeActions.add(type, label)
+```
+
+* type: The type of the action, either `change` or `revision`.
+
+* label: The label to be used in UI for this action.
+
+
+==== changeActions.remove()
+Removes an action from the change actions section.
+
+``` js
+changeActions.remove(key)
+```
+
+* key: The key of the action.
+
+
+==== changeActions.addTapListener()
+Adds a tap listener to an action that will be invoked when the action
+is tapped.
+
+``` js
+changeActions.addTapListener(key, callback)
+```
+
+* key: The key of the action.
+
+* callback: JavaScript function to be invoked when action tapped.
+
+
+==== changeActions.removeTapListener()
+Removes an existing tap listener on an action.
+
+``` js
+changeActions.removeTapListener(key, callback)
+```
+
+* key: The key of the action.
+
+* callback: JavaScript function to be removed.
+
+
+==== changeActions.setLabel()
+Sets the label for an action.
+
+``` js
+changeActions.setLabel(key, label)
+```
+
+* key: The key of the action.
+
+* label: The label of the action.
+
+
+==== changeActions.setTitle()
+Sets the title for an action.
+
+``` js
+changeActions.setTitle(key, title)
+```
+
+* key: The key of the action.
+
+* title: The title of the action.
+
+
+==== changeActions.setIcon()
+Sets an icon for an action.
+
+``` js
+changeActions.setIcon(key, icon)
+```
+
+* key: The key of the action.
+
+* icon: The name of the icon.
+
+
+==== changeActions.setEnabled()
+Sets an action to enabled or disabled.
+
+``` js
+changeActions.setEnabled(key, enabled)
+```
+
+* key: The key of the action.
+
+* enabled: The status of the action, true to enable.
+
+
+==== changeActions.setActionHidden()
+Sets an action to be hidden.
+
+``` js
+changeActions.setActionHidden(type, key, hidden)
+```
+
+* type: The type of the action.
+
+* key: The key of the action.
+
+* hidden: True to hide the action, false to show the action.
+
+
+==== changeActions.setActionOverflow()
+Sets an action to show in overflow menu.
+
+``` js
+changeActions.setActionOverflow(type, key, overflow)
+```
+
+* type: The type of the action.
+
+* key: The key of the action.
+
+* overflow: True to move the action to overflow menu, false to move
+  the action out of the overflow menu.
+
+
 === changeReply
 `plugin.changeReply()`
 
-Note: TODO
+Returns an instance of the
+link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/api/change-reply.ts[ChangeReplyPluginApi].
 
-=== delete
-`plugin.delete(url, opt_callback)`
+[[checks]]
+=== checks
+`plugin.checks()`
 
-Note: TODO
-
-=== get
-`plugin.get(url, opt_callback)`
-
-Note: TODO
+Returns an instance of the link:pg-plugin-checks-api.html[ChecksApi].
 
 === getPluginName
 `plugin.getPluginName()`
 
-Note: TODO
+Returns the name this plugin was installed as by the server
+administrator. The plugin name is required to access REST API
+views installed by the plugin, or to access resources.
 
 === getServerInfo
 `plugin.getServerInfo()`
 
-Note: TODO
-
-=== on
-`plugin.on(eventName, callback)`
-
-Note: TODO
-
-=== panel
-`plugin.panel(extensionpoint, callback)`
-
-Deprecated. Use `plugin.registerCustomComponent()` instead.
-
-``` js
-Gerrit.install(function(self) {
-  self.panel('CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK', function(context) {
-    context.body.innerHTML =
-      'Sample link: <a href="http://some.com/foo">Foo</a>';
-    context.show();
-  });
-});
-```
-
-Here's the recommended approach that uses Polymer for generating custom elements:
-
-``` js
-class SomeCiModule extends Polymer.Element {
-  static get is() {
-    return "some-ci-module";
-  }
-  static get template() {
-    return Polymer.html`
-      Sample link: <a href="http://some.com/foo">Foo</a>
-    `;
-  }
-}
-
-// Register this element
-customElements.define(SomeCiModule.is, SomeCiModule);
-
-// Install the plugin
-Gerrit.install(plugin => {
-  plugin.registerCustomComponent('change-view-integration', 'some-ci-module');
-});
-```
-
-See `samples/` for more examples.
-
-Here's a minimal example that uses low-level DOM Hooks API for the same purpose:
-
-``` js
-Gerrit.install(plugin => {
-  plugin.hook('change-view-integration', el => {
-    el.innerHTML = 'Sample link: <a href="http://some.com/foo">Foo</a>';
-  });
-});
-```
+Returns the host config as a link:rest-api-config.html#server-info[ServerInfo]
+object.
 
 === popup
 `plugin.popup(moduleName)`
 
-Note: TODO
-
-=== post
-`plugin.post(url, payload, opt_callback)`
-
-Note: TODO
+Creates a popup that contains the given web components. Can be controlled with
+calling `open()` and `close()` on the return value.
 
 [[plugin-rest-api]]
 === restApi
@@ -295,26 +409,17 @@
   e.g. `changes/1/revisions/1/cookbook~say-hello`
 
 .Returns:
-- Instance of link:pg-plugin-rest-api.html[GrPluginRestApi].
+- Instance of link:pg-plugin-rest-api.html[RestPluginApi].
 
-[[plugin-repo]]
-=== repo
-`plugin.repo()`
-
-.Params:
-- none
-
-.Returns:
-- Instance of link:pg-plugin-repo-api.html[GrRepoApi].
-
-=== put
-`plugin.put(url, payload, opt_callback)`
-
-Note: TODO
-
+[[plugin-screen]]
 === screen
 `plugin.screen(screenName, opt_moduleName)`
 
+Registers a web component as a dedicated top-level page that the router
+understands and that has a URL (/x/pluginname/screenname) that can be navigated
+to. Extension screens are usually linked from the
+link:dev-plugins.html#top-menu-extensions[top menu].
+
 .Params:
 - `*string* screenName` URL path fragment of the screen, e.g.
 `/x/pluginname/*screenname*`
@@ -322,57 +427,20 @@
 screen.
 
 .Returns:
-- Instance of GrDomHook.
-
-=== screenUrl
-`plugin.url(opt_screenName)`
-
-.Params:
-- `*string* screenName` (optional) URL path fragment of the screen, e.g.
-`/x/pluginname/*screenname*`
-
-.Returns:
-- Absolute URL for the screen, e.g. `http://localhost/base/x/pluginname/screenname`
-
-[[plugin-settings]]
-=== settings
-`plugin.settings()`
-
-.Params:
-- none
-
-.Returns:
-- Instance of link:pg-plugin-settings-api.html[GrSettingsApi].
-
-=== settingsScreen
-`plugin.settingsScreen(path, menu, callback)`
-
-Deprecated. Use link:#plugin-settings[`plugin.settings()`] instead.
-
-[[plugin-styles]]
-=== styles
-`plugin.styles()`
-
-.Params:
-- none
-
-.Returns:
-- Instance of link:pg-plugin-styles-api.html[GrStylesApi]
-
-=== changeMetadata
-`plugin.changeMetadata()`
-
-.Params:
-- none
-
-.Returns:
-- Instance of link:pg-plugin-change-metadata-api.html[GrChangeMetadataApi].
-
-=== theme
-`plugin.theme()`
-
-
-Note: TODO
+- Instance of HookApi.
 
 === url
-`plugin.url(opt_path)`
\ No newline at end of file
+`plugin.url(opt_path)`
+
+Returns a URL within the plugin's URL space. If invoked with no
+parameter the URL of the plugin is returned. If passed a string
+the argument is appended to the plugin URL.
+
+A plugin's URL is where this plugin is loaded, it doesn't
+necessary to be the same as the Gerrit host. Use `window.location`
+if you need to access the Gerrit host info.
+
+``` js
+self.url();                    // "https://gerrit-review.googlesource.com/plugins/demo/"
+self.url('/static/icon.png');  // "https://gerrit-review.googlesource.com/plugins/demo/static/icon.png"
+```
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index a8b3330..c16d0d4 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -1,16 +1,24 @@
-= Gerrit Code Review - PolyGerrit Plugin Styling
+= Gerrit Code Review - JavaScript Plugin Endpoints
 
-Plugins should be html-based and imported following PolyGerrit's
-link:pg-plugin-dev.html#loading[dev guide].
+This document describes Gerrit JavaScript plugin endpoints that you can hook
+into for customizing the UI. It is assumed that you are familiar with
+link:pg-plugin-dev.html#loading[the general dev guide].
 
-Sample code for testing endpoints:
+You can either hook into an endpoint by calling `plugin.hook(endpoint)` and
+then interact with the returned `HookApi`, which has `onAttached(callback)` and
+`onDetached(callback)` methods.
+
+Or you can define a
+link:https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements[Web Component,role=external,window=_blank]
+and register it directly using
+`plugin.registerCustomComponent(endpoint, elementName)`.
+
+Sample code for using an endpoint:
 
 ``` js
 Gerrit.install(plugin => {
-  // Change endpoint below
   const endpoint = 'change-metadata-item';
   plugin.hook(endpoint).onAttached(element => {
-    console.log(endpoint, element);
     const el = element.appendChild(document.createElement('div'));
     el.textContent = 'Ah, there it is. Lovely.';
     el.style = 'background: pink; line-height: 4em; text-align: center;';
@@ -42,9 +50,8 @@
 
 === change-view-integration
 The `change-view-integration` extension point is located between `Files` and
-`Messages` section on the change view page, and it may take full page's
-width. Primary purpose is to enable plugins to display custom CI-related
-information (build status, etc).
+`Change Log` section on the change view page, and it may take full page's
+width.
 
 * `change`
 +
@@ -174,7 +181,8 @@
 
 == Dynamic Plugin endpoints
 
-The following endpoints are available to plugins.
+The following dynamic endpoints are available to plugins by calling
+`plugin.registerDynamicCustomComponent(endpoint, elementName)`.
 
 === change-list-header
 The `change-list-header` extension point adds a header to the change list view.
diff --git a/Documentation/pg-plugin-migration.txt b/Documentation/pg-plugin-migration.txt
deleted file mode 100644
index 061c687..0000000
--- a/Documentation/pg-plugin-migration.txt
+++ /dev/null
@@ -1,148 +0,0 @@
-:linkattrs:
-= Gerrit Code Review - PolyGerrit Plugin Development
-
-CAUTION: Work in progress. Hard hat area. Please
-link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20plugins[send
-feedback,role=external,window=_blank] if something's not right.
-
-[[migration]]
-== Incremental migration of existing GWT UI plugins
-
-link:pg-plugin-dev.html[PolyGerrit plugin API] is based on different concepts and
-provides a different type of API compared to the one available to GWT
-plugins. Depending on the plugin, it might require significant modifications to
-existing UI scripts to fully take advantage of the benefits provided by the PolyGerrit API.
-
-To make migration easier, PolyGerrit recommends an incremental migration
-strategy. Starting with a .js file that works for GWT UI, plugin author can
-incrementally migrate deprecated APIs to the new plugin API.
-
-The goal for this guide is to provide a migration path from .js-based UI script to
-a html based implementation
-
-NOTE: Web UI plugins distributed as a single .js file are not covered in this
-guide.
-
-Let's start with a basic plugin that has an UI module. Commonly, file tree
-should look like this:
-
-  ├── BUILD
-  ├── LICENSE
-  └── src
-      └── main
-          ├── java
-          │   └── com
-          │       └── foo
-          │           └── SamplePluginModule.java
-          └── resources
-              └── static
-                  └── sampleplugin.js
-
-For simplicity's sake, let's assume SamplePluginModule.java has following
-content:
-
-``` java
-public class SamplePluginModule extends AbstractModule {
-
-  @Override
-  protected void configure() {
-    DynamicSet.bind(binder(), WebUiPlugin.class)
-        .toInstance(new JavaScriptPlugin("sampleplugin.js"));
-  }
-}
-```
-
-=== Step 1: Create `sampleplugin.html`
-
-As a first step, create `sampleplugin.html` and include the UI script in the
-module file.
-
-NOTE: GWT UI ignores html files which it doesn't support.
-
-``` java
-  @Override
-  protected void configure() {
-    DynamicSet.bind(binder(), WebUiPlugin.class)
-        .toInstance(new JavaScriptPlugin("sampleplugin.js"));
-    DynamicSet.bind(binder(), WebUiPlugin.class)
-        .toInstance(new JavaScriptPlugin("sampleplugin.html"));
-  }
-```
-
-Here's recommended starter code for `sampleplugin.html`:
-
-NOTE: By specification, the `id` attribute of `dom-module` *must* contain a dash
-(-).
-
-``` html
-<dom-module id="sample-plugin">
-  <script>
-    Gerrit.install(plugin => {
-        // Setup block, is executed before sampleplugin.js
-    });
-  </script>
-
-  <script src="./sampleplugin.js"></script>
-
-  <script>
-    Gerrit.install(plugin => {
-        // Cleanup block, is executed after sampleplugin.js
-    });
-  </script>
-</dom-module>
-```
-
-Here's how this works:
-
-- PolyGerrit detects migration scenario because UI scripts have same filename
-and different extensions
- * PolyGerrit will load `sampleplugin.html` and skip `sampleplugin.js`
- * PolyGerrit will reuse `plugin` (aka `self`) instance for `Gerrit.install()`
-callbacks
-- `sampleplugin.js` is loaded since it's referenced in `sampleplugin.html`
-- setup script tag code is executed before `sampleplugin.js`
-- cleanup script tag code is executed after `sampleplugin.js`
-
-This means the plugin instance is shared between .html-based and .js-based
-code. This allows to gradually and incrementally transfer code to the new API.
-
-=== Step 2: Create cut-off marker in `sampleplugin.js`
-
-Commonly, window.Polymer is being used to detect in GWT UI script if it's being
-executed inside PolyGerrit. This could be used to separate code that was already
-migrated to new APIs from old not yet migrated code.
-
-During incremental migration, some of the UI code will be reimplemented using
-the PolyGerrit plugin API. However, old code still could be required for the plugin
-to work in GWT UI.
-
-To handle this case, add the following code at the end of the installation
-callback in `sampleplugin.js`
-
-``` js
-Gerrit.install(function(self) {
-
-  // Existing code here, not modified.
-
-  if (window.Polymer) { return; } // Cut-off marker
-
-  // Everything below was migrated to PolyGerrit plugin API.
-  // Code below is still needed for the plugin to work in GWT UI.
-});
-```
-
-=== Step 3: Migrate!
-
-The code that uses deprecated APIs should be eventually rewritten using
-non-deprecated counterparts. Duplicated pieces could be kept under cut-off
-marker to work in GWT UI.
-
-If some data or functions needs to be shared between code in .html and .js, it
-could be stored in the `plugin` (aka `self`) object that's shared between both
-
-=== Step 4: Cleanup
-
-Once deprecated APIs are migrated, `sampleplugin.js` will only contain
-duplicated code that's required for GWT UI to work. As soon as GWT support is removed from Gerrit
-that file can be simply deleted, along with the script tag loading it.
-
diff --git a/Documentation/pg-plugin-repo-api.txt b/Documentation/pg-plugin-repo-api.txt
deleted file mode 100644
index 1272ea6..0000000
--- a/Documentation/pg-plugin-repo-api.txt
+++ /dev/null
@@ -1,36 +0,0 @@
-= Gerrit Code Review - Repo admin customization API
-
-This API is provided by link:pg-plugin-dev.html#plugin-repo[plugin.repo()]
-and provides customization to admin page.
-
-== createCommand
-`repoApi.createCommand(title, checkVisibleCallback)`
-
-Create a repo command in the admin panel.
-
-.Params
-- *title* String title.
-- *checkVisibleCallback* function to configure command visibility.
-
-.Returns
-- GrRepoApi for chaining.
-
-`checkVisibleCallback(repoName, repoConfig)`
-
-.Params
-- *repoName* String project name.
-- *repoConfig* Object REST API response for repo config.
-
-.Returns
-- `false` to hide the command for the specific project.
-
-== onTap
-`repoApi.onTap(tapCalback)`
-
-Add a command tap callback.
-
-.Params
-- *tapCallback* function that's excuted on command tap.
-
-.Returns
-- Nothing
diff --git a/Documentation/pg-plugin-rest-api.txt b/Documentation/pg-plugin-rest-api.txt
index 0d42bf6..4771fbb 100644
--- a/Documentation/pg-plugin-rest-api.txt
+++ b/Documentation/pg-plugin-rest-api.txt
@@ -1,4 +1,4 @@
-= Gerrit Code Review - Repo admin customization API
+= Gerrit Code Review - JavaScript Plugin Rest API
 
 This API is provided by link:pg-plugin-dev.html#plugin-rest-api[plugin.restApi()]
 and provides interface for Gerrit REST API.
@@ -28,13 +28,14 @@
 == getConfig
 `repoApi.getConfig()`
 
-Get server config.
+Returns the host config as a link:rest-api-config.html#server-info[ServerInfo]
+object.
 
 .Params
 - None
 
 .Returns
-- Promise<Object>
+- Promise<ServerInfo>
 
 == get
 `repoApi.get(url)`
diff --git a/Documentation/pg-plugin-settings-api.txt b/Documentation/pg-plugin-settings-api.txt
deleted file mode 100644
index 985809d..0000000
--- a/Documentation/pg-plugin-settings-api.txt
+++ /dev/null
@@ -1,40 +0,0 @@
-= Gerrit Code Review - Settings admin customization API
-
-This API is provided by link:pg-plugin-dev.html#plugin-settings[plugin.settings()]
-and provides customization to settings page.
-
-== title
-`settingsApi.title(title)`
-
-.Params
-- `*string* title` Menu item and settings section title
-
-.Returns
-- `GrSettingsApi` for chaining.
-
-== token
-`settingsApi.token(token)`
-
-.Params
-- `*string* token` URL path fragment of the screen for direct link, e.g.
-`settings/#x/some-plugin/*token*`
-
-.Returns
-- `GrSettingsApi` for chaining.
-
-== module
-`settingsApi.module(token)`
-
-.Params
-- `*string* module` Custom element name for instantiating in the settings plugin
-area.
-
-.Returns
-- `GrSettingsApi` for chaining.
-
-== build
-
-.Params
-- none
-
-Apply all other configuration parameters and create required UI elements.
diff --git a/Documentation/pg-plugin-style-object.txt b/Documentation/pg-plugin-style-object.txt
deleted file mode 100644
index cdcfb55..0000000
--- a/Documentation/pg-plugin-style-object.txt
+++ /dev/null
@@ -1,33 +0,0 @@
-= Gerrit Code Review - GrStyleObject
-
-Store information about css style properties. You can't create this object
-directly. Instead you should use the link:pg-plugin-styles-api.html#css[css] method.
-This object allows to apply style correctly to elements within different shadow
-subtree.
-
-[[get-class-name]]
-== getClassName
-`styleObject.getClassName(element)`
-
-.Params
-- `element` - an HTMLElement.
-
-.Returns
-- `string` - class name. The class name is valid only within the shadow root of `element`.
-
-Creates a new unique CSS class and injects it into the appropriate place
-in DOM (it can be document or shadow root for element). This class can be later
-added to the element or to any other element in the same shadow root. It is guarantee,
-that method adds CSS class only once for each shadow root.
-
-== apply
-`styleObject.apply(element)`
-
-.Params
-- `element` - element to apply style.
-
-Create a new unique CSS class (see link:#get-class-name[getClassName]) and
-adds class to the element.
-
-
-
diff --git a/Documentation/pg-plugin-styles-api.txt b/Documentation/pg-plugin-styles-api.txt
deleted file mode 100644
index a829325..0000000
--- a/Documentation/pg-plugin-styles-api.txt
+++ /dev/null
@@ -1,29 +0,0 @@
-= Gerrit Code Review - Plugin styles API
-
-This API is provided by link:pg-plugin-dev.html#plugin-styles[plugin.styles()]
-and provides a way to apply dynamically created styles to elements in a
-document.
-
-[[css]]
-== css
-`styles.css(rulesStr)`
-
-.Params
-- `*string* rulesStr` string with CSS styling declarations.
-
-Example:
-----
-const styleObject = plugin.styles().css('background: black; color: white;');
-...
-const className = styleObject.getClassName(element)
-...
-element.classList.add(className);
-...
-styleObject.apply(someOtherElement);
-----
-
-.Returns
-- Instance of link:pg-plugin-style-object.html[GrStyleObject].
-
-
-
diff --git a/Documentation/pg-plugin-styling.txt b/Documentation/pg-plugin-styling.txt
deleted file mode 100644
index f600376..0000000
--- a/Documentation/pg-plugin-styling.txt
+++ /dev/null
@@ -1,72 +0,0 @@
-:linkattrs:
-= Gerrit Code Review - PolyGerrit Plugin Styling
-
-== Plugin styles
-
-Plugins may provide
-link:https://www.polymer-project.org/2.0/docs/devguide/style-shadow-dom#style-modules[Polymer
-style modules,role=external,window=_blank] for UI CSS-based customization.
-
-PolyGerrit UI implements number of styling endpoints, which apply CSS mixins
-link:https://tabatkins.github.io/specs/css-apply-rule/[using @apply,role=external,window=_blank] to its
-direct contents.
-
-NOTE: Only items (i.e. CSS properties and mixin targets) documented here are
-guaranteed to work in the long term, since they are covered by integration
-tests. + When there is a need to add new property or endpoint, please
-link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20Issue[file
-a bug,role=external,window=_blank] stating your use case to track and maintain for future releases.
-
-Plugins should be html-based and imported following PolyGerrit's
-link:pg-plugin-dev.html#loading[dev guide].
-
-Plugins should provide Style Module, for example:
-
-``` html
-  <dom-module id="some-style">
-    <template>
-      <style>
-        html {
-          --css-mixin-name: {
-            property: value;
-          }
-        }
-      </style>
-    </template>
-  </dom-module>
-```
-
-Plugins should register style module with a styling endpoint using
-`Plugin.prototype.registerStyleModule(endpointName, styleModuleName)`, for
-example:
-
-``` js
-  Gerrit.install(function(plugin) {
-    plugin.registerStyleModule('some-endpoint', 'some-style');
-  });
-```
-
-== Available styling endpoints
-=== change-metadata
-Following custom CSS mixins are recognized:
-
-* `--change-metadata-assignee`
-+
-is applied to `gr-change-metadata section.assignee`
-* `--change-metadata-label-status`
-+
-is applied to `gr-change-metadata section.labelStatus`
-* `--change-metadata-strategy`
-+
-is applied to `gr-change-metadata section.strategy`
-* `--change-metadata-topic`
-+
-is applied to `gr-change-metadata section.topic`
-
-Following CSS properties have
-link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html[long-term
-support via integration test,role=external,window=_blank]:
-
-* `display`
-+
-can be set to `none` to hide a section.
diff --git a/Documentation/project-configuration.txt b/Documentation/project-configuration.txt
index 23030a4..e583f45 100644
--- a/Documentation/project-configuration.txt
+++ b/Documentation/project-configuration.txt
@@ -108,6 +108,9 @@
 === Default Branch
 
 The default branch of a remote repository is defined by its `HEAD`.
+The default branch is selected from the initial branches of the newly created project,
+or set to link:config-gerrit.html#gerrit.defaultBranch[host-level default],
+if the project was created with empty branches.
 For convenience reasons, when the repository is cloned Git creates a
 local branch for this default branch and checks it out.
 
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 32c30b8..189ccfc 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -26,7 +26,7 @@
 link:https://groups.google.com/d/topic/repo-discuss/wJxTGhlHZMM/discussion[This
 discussion thread,role=external,window=_blank] explains why Prolog was chosen for the purpose of writing
 project specific submit rules.
-link:http://gerrit-documentation.googlecode.com/svn/ReleaseNotes/ReleaseNotes-2.2.2.html[Gerrit
+link:https://gerrit-documentation.storage.googleapis.com/ReleaseNotes/ReleaseNotes-2.2.2.html#_prolog[Gerrit
 2.2.2 ReleaseNotes,role=external,window=_blank] introduces Prolog support in Gerrit.
 
 [[SubmitType]]
diff --git a/Documentation/repository-maintenance.txt b/Documentation/repository-maintenance.txt
new file mode 100644
index 0000000..1672436
--- /dev/null
+++ b/Documentation/repository-maintenance.txt
@@ -0,0 +1,116 @@
+= Gerrit Code Review - Repository Maintenance
+
+== Description
+
+Each project in Gerrit is stored in a bare Git repository. Gerrit uses
+the JGit library to access (read and write to) these Git repositories.
+As modifications are made to a project, Git repository maintenance will
+be needed or performance will eventually suffer. When using the Git
+command line tool to operate on a Git repository, it will run `git gc`
+every now and then on the repository to ensure that Git garbage
+collection is performed. However regular maintenance does not happen as
+a result of normal Gerrit operations, so this is something that Gerrit
+administrators need to plan for.
+
+Gerrit has a built-in feature which allows it to run Git garbage
+collection on repositories. This can be
+link:config-gerrit.html#gc[configured] to run on a regular basis, and/or
+this can be run manually with the link:cmd-gc.html[gerrit gc] ssh
+command, or with the link:rest-api-projects.html#run-gc[run-gc] REST API.
+Some administrators will opt to run `git gc` or `jgit gc` outside of
+Gerrit instead. There are many reasons this might be done, the main one
+likely being that when it is run in Gerrit it can be very resource
+intensive and scheduling an external job to run Git garbage collection
+allows administrators to finely tune the approach and resource usage of
+this maintenance.
+
+== Git Garbage Collection Impacts
+
+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
+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
+more likely to occur when there are more concurrent operations. Since
+most Gerrit servers are expected to run without interruptions, Git
+garbage collection likely needs to be run during normal operational hours.
+When it runs, it adds to the concurrency of the overall accesses. Given
+that many of the operations in garbage collection involve deleting files
+and directories, it has a higher chance of impacting other ongoing
+operations than most other operations.
+
+=== Interrupted Operations
+
+When Git garbage collection deletes a file or directory that is
+currently in use by an ongoing operation, it can cause that operation to
+fail. These sorts of failures are often single shot failures, i.e. the
+operation will succeed if tried again. An example of such a failure is
+when a pack file is deleted while Gerrit is sending an object in the
+file over the network to a user performing a clone or fetch. Usually
+pack files are only deleted when the referenced objects in them have
+been repacked and thus copied to a new pack file. So performing the same
+operation again after the fetch will likely send the same object from
+the new pack instead of the deleted one, and the operation will succeed.
+
+=== Data Loss
+
+It is possible for data loss to occur when Git garbage collection runs.
+This is very rare, but it can happen. This can happen when an object is
+believed to be unreferenced when object repacking is running, and then
+garbage collection deletes it. This can happen because even though an
+object may indeed be unreferenced when object repacking begins and
+reachability of all objects is determined, it can become referenced by
+another concurrent operation after this unreferenced determination but
+before it gets deleted. When this happens, a new reference can be
+created which points to a now missing object, and this will result in a
+loss.
+
+== Reducing Git Garbage Collection Impacts
+
+JGit has a `preserved` directory feature which is intended to reduce
+some of the impacts of Git garbage collection, and Gerrit can take
+advantage of the feature too. The `preserved` directory is a
+subdirectory of a repository's `objects/pack` directory where JGit will
+move pack files that it would normally delete when `jgit gc` is invoked
+with the `--preserve-oldpacks` option. It will later delete these files
+the next time that `jgit gc` is run if it is invoked with the
+`--prune-preserved` option. Using these flags together on every `jgit gc`
+invocation means that packfiles will get an extended lifetime by one
+full garbage collection cycle. Since an atomic move is used to move these
+files, any open references to them will continue to work, even on NFS. On
+a busy repository, preserving pack files can make operations much more
+reliable, and interrupted operations should almost entirely disappear.
+
+Moving files to the `preserved` directory also has the ability to reduce
+data loss. If JGit cannot find an object it needs in its current object
+DB, it will look into the `preserved` directory as a last resort. If it
+finds the object in a pack file there, it will restore the
+slated-to-be-deleted pack file back to the original `objects/pack`
+directory effectively "undeleting" it and making all the objects in it
+available again. When this happens, data loss is prevented.
+
+One advantage of restoring preserved packfiles in this way when an
+object is referenced in them, is that it makes loosening unreferenced
+objects during Git garbage collection, which is a potentially expensive,
+wasteful, and performance impacting operation, no longer desirable. It
+is recommended that if you use Git for garbage collection, that you use
+the `-a` option to `git repack` instead of the `-A` option to no longer
+perform this loosening.
+
+When Git is used for garbage collection instead of JGit, it is fairly
+easy to wrap `git gc` or `git repack` with a small script which has a
+`--prune-preserved` option which behaves as mentioned above by deleting
+any pack files currently in the preserved directory, and also has a
+`--preserve-oldpacks` option which then hardlinks all the currently
+existing pack files from the `objects/pack` directory into the
+`preserved` directory right before calling the real Git command. This
+approach will then behave similarly to `jgit gc` with respect to
+preserving pack files.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index 6664aa2..45a39d8 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -406,14 +406,15 @@
 |`config_visible`     |not set if `false`|
 Whether the calling user can see the `refs/meta/config` branch of the
 project.
-|`groups`            ||A map of group UUID to
+|`groups`             ||A map of group UUID to
 link:rest-api-groups.html#group-info[GroupInfo] objects, with names and
 URLs for the group UUIDs used in the `local` map.
 This will include names for groups that might
 be invisible to the caller.
-|`configWebLinks`    ||
-A list of URLs that display the history of the configuration file
-governing this project's access rights.
+|`config_web_links`   |optional|
+Links to the history of the configuration file governing this project's access
+rights as list of link:rest-api-changes.html#web-link-info[WebLinkInfo]
+entities.
 |==================================
 
 
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 2a59d0c..8aa3173 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -2773,7 +2773,7 @@
 Allowed values are `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
-(PolyGerrit only).
+(Gerrit web app UI only).
 |`download_scheme`              |optional|
 The type of download URL the user prefers to use. May be any key from
 the `schemes` map in
@@ -2802,8 +2802,8 @@
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
 |`change_table`                           ||
-The columns to display in the change table (PolyGerrit only). The default is
-empty, which will default columns as determined by the frontend.
+The columns to display in the change table (Gerrit web app UI only). The
+default is empty, which will default columns as determined by the frontend.
 |`email_strategy`               ||
 The type of email strategy to use. On `ENABLED`, the user will receive emails
 from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
@@ -2816,6 +2816,8 @@
 The base which should be pre-selected in the 'Diff Against' drop-down
 list when the change screen is opened for a merge commit.
 Allowed values are `AUTO_MERGE` and `FIRST_PARENT`.
+|`disable_keyboard_shortcuts`     |not set if `false`|
+Whether to disable all keyboard shortcuts.
 |`publish_comments_on_push`     |not set if `false`|
 Whether to link:user-upload.html#publish-comments[publish draft comments] on
 push by default.
@@ -2840,7 +2842,7 @@
 Allowed values are `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
-(PolyGerrit only).
+(Gerrit web app UI only).
 |`download_scheme`              |optional|
 The type of download URL the user prefers to use.
 |`date_format`                  |optional|
@@ -2867,8 +2869,8 @@
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
 |`change_table`                           ||
-The columns to display in the change table (PolyGerrit only). The default is
-empty, which will default columns as determined by the frontend.
+The columns to display in the change table (Gerrit web app UI only). The
+default is empty, which will default columns as determined by the frontend.
 |`email_strategy`               |optional|
 The type of email strategy to use. On `ENABLED`, the user will receive emails
 from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 32bfc6b..8da7a9d 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -14,8 +14,11 @@
 'POST /changes/'
 --
 
-The change input link:#change-input[ChangeInput] entity must be provided in the
-request body.
+The change input link:#change-input[ChangeInput] entity must be
+provided in the request body. It is not allowed to create changes
+under `refs/tags/` or Gerrit internal ref namespaces such as
+`refs/changes/`, `refs/meta/external-ids/`, and `refs/users/`. The
+request would fail with `400 Bad Request` in this case.
 
 To create a change the calling user must be allowed to
 link:access-control.html#category_push_review[upload to code review].
@@ -561,6 +564,72 @@
   }
 ----
 
+Historical state of the change can be retrieved by specifying the
+`meta=SHA-1` parameter. This will use a historical NoteDb snapshot to
+populate ChangeInfo. If the SHA-1 is not reachable as a NoteDb state,
+status code 412 is returned.
+
+[[get-meta-diff]]
+=== Get Meta Diff
+--
+'GET /changes/link:#change-id[\{change-id\}]/meta_diff?old=SHA-1&meta=SHA-1'
+--
+
+Retrieves the difference between two historical states of a change
+by specifying the `old=SHA-1` and the `meta=SHA-1` parameters.
+
+If the `old` parameter is not provided, the parent of the `meta`
+SHA-1 is used. If the `meta` parameter is not provided, the current
+state of the change is used. If neither are provided, the
+difference between the current state of the change and its previous
+state is returned.
+
+Additional fields can be obtained by adding `o` parameters, analogous
+to link:#get-change[Get Change], and the same concerns for Get Change hold for
+this endpoint too. Fields are described in link:#list-changes[Query Changes].
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/meta_diff?old=b083abc34eb6dbdb9e154ba092fc198000e997b4&meta=63b81f2bde703ae07787a940e8fdf9a1828605b1 HTTP/1.0
+----
+
+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.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "added": {
+      "attention_set": [
+        {
+          "account": {
+            "name": "John Doe"
+          },
+         "last_update": "2013-02-21 11:16:36.775000000",
+         "reason": "reviewer or cc replied"
+        }
+      ]
+      "updated": "2013-02-21 11:16:36.775000000",
+      "topic": "new-topic"
+    },
+    "removed": {
+      "updated": "2013-02-20 12:05:34.111000000",
+      "topic": "old-topic"
+    }
+  }
+----
+
+If the provided SHA-1 for `meta` is not reachable as a NoteDb
+state, the status code 412 is returned. If the SHA-1 for `old`
+is not reachable, the difference between the change at state
+`meta` and an empty change is returned.
+
 [[get-change-detail]]
 === Get Change Detail
 --
@@ -1131,7 +1200,7 @@
 --
 
 Check if the given change is a pure revert of the change it references in `revertOf`.
-Optionally, the query parameter `o` can be passed in to specify a commit (SHA1 in
+Optionally, the query parameter `o` can be passed in to specify a commit (SHA-1 in
 40 digit hex representation) to check against. It takes precedence over `revertOf`.
 If the change has no reference in `revertOf`, the parameter is mandatory.
 
@@ -1394,6 +1463,8 @@
 
 The destination branch must be provided in the request body inside a
 link:#move-input[MoveInput] entity.
+Only veto votes that are blocking the change from submission are moved to
+the destination branch by default.
 
 .Request
 ----
@@ -2048,10 +2119,14 @@
 comments for each path are sorted by patch set number. Each comment has
 the `patch_set` and `author` fields set.
 
-If the `enable_context` request parameter is set to true, the comment entries
+If the `enable-context` request parameter is set to true, the comment entries
 will contain a list of link:#context-line[ContextLine] containing the lines of
 the source file where the comment was written.
 
+The `context-padding` request parameter can be used to specify an extra number
+of context lines to be added before and after the comment range. This parameter
+only works if `enable-context` is set to true.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/comments HTTP/1.0
@@ -2165,6 +2240,10 @@
 comments for each path are sorted by patch set number. Each comment has
 the `patch_set` field set, and no `author`.
 
+The `enable-context` and `context-padding` request parameters can be used to
+request comment context. See link:#list-change-comments[List Change Comments]
+for more details.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/drafts HTTP/1.0
@@ -5143,6 +5222,8 @@
 Different than the link:#get-ported-comments[Get Ported Comments] endpoint, the `author` of the
 returned comments is not filled for this endpoint as only comments of the calling user are returned.
 
+This endpoint requires authentication.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/ported_drafts/ HTTP/1.0
@@ -5944,6 +6025,8 @@
 If a user is added while already in the attention set, the
 request is silently ignored.
 
+The user must be a reviewer, cc, uploader, or owner on the change.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention HTTP/1.0
@@ -6454,6 +6537,8 @@
 Only set if link:#current-revision[the current revision] is requested
 (in which case it will only contain a key for the current revision) or
 if link:#all-revisions[all revisions] are requested.
+|`meta_rev_id`           |optional|
+The SHA-1 of the NoteDb meta ref.
 |`tracking_ids`       |optional|
 A list of link:#tracking-id-info[TrackingIdInfo] entities describing
 references to external tracking systems. Only set if
@@ -6480,8 +6565,12 @@
 The callers must not rely on the format of the submission ID.
 |`cherry_pick_of_change`   |optional|
 The numeric Change-Id of the change that this change was cherry-picked from.
+Only set if the cherry-pick has been done through the Gerrit REST API (and
+not if a cherry-picked commit was pushed).
 |`cherry_pick_of_patch_set`|optional|
 The patchset number of the change that this change was cherry-picked from.
+Only set if the cherry-pick has been done through the Gerrit REST API (and
+not if a cherry-picked commit was pushed).
 |`contains_git_conflicts`  |optional, not set if `false`|
 Whether the change contains conflicts. +
 If `true`, some of the file contents of the change contain git conflict
@@ -6687,12 +6776,16 @@
 Contains the link:rest-api-changes.html#change-message-info[id] of the change
 message that this comment is linked to.
 |`commit_id` |optional|
-Hex commit SHA1 (40 characters string) of the commit of the patchset to which
+Hex commit SHA-1 (40 characters string) of the commit of the patchset to which
 this comment applies.
 |`context_lines` |optional|
 A list of link:#context-line[ContextLine] containing the lines of the source
-file where the comment was written. Available only if the "enable_context"
+file where the comment was written. Available only if the "enable-context"
 parameter (see link:#list-change-comments[List Change Comments]) is set.
+|`source_content_type` |optional|
+Mime type of the file where the comment is written. Available only if the
+"enable-context" parameter (see link:#list-change-comments[List Change Comments])
+is set.
 
 |===========================
 
@@ -7356,6 +7449,11 @@
 |`destination_branch`||Destination branch
 |`message`           |optional|
 A message to be posted in this change's comments
+|`keep_all_votes`    |optional, defaults to false|
+By default, only veto votes that are blocking the change from submission are moved to
+the destination branch. Using this option is only allowed for administrators,
+because it can affect the submission behaviour of the change (depending on the label access
+configuration and submissions rules).
 |===========================
 
 [[notify-info]]
@@ -7470,11 +7568,18 @@
 |===========================
 |Field Name    ||Description
 |`base`        |optional|
-The new parent revision. This can be a ref or a SHA1 to a concrete patchset. +
+The new parent revision. This can be a ref or a SHA-1 to a concrete patchset. +
 Alternatively, a change number can be specified, in which case the current
 patch set is inferred. +
 Empty string is used for rebasing directly on top of the target branch,
 which effectively breaks dependency towards a parent change.
+|`allow_conflicts`|optional, defaults to false|
+If `true`, the rebase also succeeds if there are conflicts. +
+If there are conflicts the file contents of the rebased patch set contain
+git conflict markers to indicate the conflicts. +
+Callers can find out whether there were conflicts by checking the
+`contains_git_conflicts` field in the returned link:#change-info[ChangeInfo]. +
+If there are conflicts the change is marked as work-in-progress.
 |===========================
 
 [[related-change-and-commit-info]]
@@ -7627,9 +7732,12 @@
 The message to be added as review comment.
 |`tag`                                  |optional|
 Apply this tag to the review comment message, votes, and inline
-comments. Tags may be used by CI or other automated systems to
-distinguish them from human reviews. Votes/comments that contain `tag` with
-'autogenerated:' prefix can be filtered out in the web UI.
+comments. Tags with an 'autogenerated:' prefix may be used by CI or other
+automated systems to distinguish them from human reviews. If another
+message was posted on a newer patchset, but with the same tag, then the older
+message will be hidden in the UI. Suffixes starting with `~` are not considered,
+so `autogenerated:my-ci-system~trigger` and `autogenerated:my-ci-system~result`
+will be considered being the same tag with regards to the hiding rule.
 |`labels`                               |optional|
 The votes that should be added to the revision as a map that maps the
 label names to the voting values.
@@ -7674,7 +7782,8 @@
 `ready` and `work_in_progress` to be true.
 |`add_to_attention_set`                |optional|
 list of link:#attention-set-input[AttentionSetInput] entities to add
-to the link:#attention-set[attention set].
+to the link:#attention-set[attention set]. Users that are not reviewers,
+ccs, owner, or uploader are silently ignored.
 |`remove_from_attention_set`           |optional|
 list of link:#attention-set-input[AttentionSetInput] entities to remove
 from the link:#attention-set[attention set].
@@ -8061,13 +8170,14 @@
 === WebLinkInfo
 The `WebLinkInfo` entity describes a link to an external site.
 
-[options="header",cols="1,6"]
-|======================
-|Field Name|Description
-|`name`    |The link name.
-|`url`     |The link URL.
-|`image_url`|URL to the icon of the link.
-|======================
+[options="header",cols="1,^1,5"]
+|========================
+|Field Name ||Description
+|`name`     ||The link name.
+|`url`      ||The link URL.
+|`image_url`|optional|URL to the icon of the link.
+|`target`   |optional|The target window in which the web link should be opened.
+|========================
 
 [[work-in-progress-input]]
 === WorkInProgressInput
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index a62ed47..41a8729 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1552,9 +1552,6 @@
 |`allow_blame`        |not set if `false`|
 link:config-gerrit.html#change.allowBlame[Whether blame on side by side diff is
 allowed].
-|`large_change`       ||
-link:config-gerrit.html#change.largeChange[Number of changed lines from
-which on a change is considered as a large change].
 |`reply_label`        ||
 link:config-gerrit.html#change.replyTooltip[Label name for the reply
 button].
@@ -1842,6 +1839,8 @@
 Whether to enable the web UI for editing GPG keys.
 |`report_bug_url`    |optional|
 link:config-gerrit.html#gerrit.reportBugUrl[URL to report bugs].
+|`instance_id`       |optional|
+link:config-gerrit.html#gerrit.instanceId[Short identifier for this Gerrit installation].
 |=================================
 
 [[index-config-info]]
@@ -1942,6 +1941,9 @@
 |Field Name    ||Description
 |`has_avatars` |not set if `false`|
 Whether an avatar provider is registered.
+|`js_resource_paths`||
+A list of relative paths (strings). Each path points to a frontend plugin that
+should be loaded, e.g. `plugins/codemirror_editor/static/codemirror_editor.js`.
 |===========================
 
 [[receive-info]]
@@ -2010,7 +2012,7 @@
 link:config-gerrit.html#user[user] section as link:#user-config-info[
 UserConfigInfo] entity.
 |`default_theme`           |optional|
-URL to a default PolyGerrit UI theme plugin, if available.
+URL to a default Gerrit UI theme plugin, if available.
 Located in `/static/gerrit-theme.js` by default.
 |=======================================
 
diff --git a/Documentation/rest-api-documentation.txt b/Documentation/rest-api-documentation.txt
index 0a7ff16..e69750d 100644
--- a/Documentation/rest-api-documentation.txt
+++ b/Documentation/rest-api-documentation.txt
@@ -47,10 +47,6 @@
       "url": "Documentation/rest-api.html"
     },
     {
-      "title": "Gerrit Code Review - JavaScript API",
-      "url": "Documentation/js-api.html"
-    },
-    {
       "title": "Gerrit Code Review - /plugins/ REST API",
       "url": "Documentation/rest-api-plugins.html"
     },
@@ -67,10 +63,14 @@
       "url": "Documentation/rest-api-access.html"
     },
     {
-      "title": "Gerrit Code Review - Plugin Development",
+      "title": "Gerrit Code Review - Java Plugin Development",
       "url": "Documentation/dev-plugins.html"
     },
     {
+      "title": "Gerrit Code Review - JavaScript Plugin Development and API",
+      "url": "Documentation/pg-plugin-dev.html"
+    },
+    {
       "title": "Gerrit Code Review - Developer Setup",
       "url": "Documentation/dev-readme.html"
     },
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 84da169..eee3d3e 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1702,7 +1702,9 @@
 'GET /projects/link:#project-name[\{project-name\}]/branches/link:#branch-id[\{branch-id\}]'
 --
 
-Retrieves a branch of a project.
+Retrieves a branch of a project. For the "All-Users" repository, the magic
+branch "refs/users/self" is automatically resolved to the user branch of the
+calling user.
 
 .Request
 ----
@@ -1734,7 +1736,9 @@
 Creates a new branch.
 
 In the request body additional data for the branch can be provided as
-link:#branch-input[BranchInput].
+link:#branch-input[BranchInput]. The link:#branch-id[\{branch-id\}] in the URL
+should exactly match with the `ref` field of link:#branch-input[BranchInput], or
+otherwise the request would fail with `400 Bad Request`.
 
 .Request
 ----
@@ -1846,8 +1850,9 @@
 
 Gets whether the source is mergeable with the target branch.
 
-The `source` query parameter is required, which can be anything that could be
-resolved to a commit, see examples of the `source` attribute in
+The `source` query parameter is required, which can be anything that
+could be resolved to a commit, and is visible to the caller. See
+examples of the `source` attribute in
 link:rest-api-changes.html#merge-input[MergeInput].
 
 Also takes an optional parameter `strategy`, which can be `recursive`, `resolve`,
@@ -2702,7 +2707,10 @@
 The integer-valued request parameter `parent` changes the response to return a
 list of the files which are different in this commit compared to the given
 parent commit. This is useful for supporting review of merge commits. The value
-is the 1-based index of the parent's position in the commit object.
+is the 1-based index of the parent's position in the commit object. If the
+value 0 is used for `parent`, the default base commit will be used, which is
+the only parent for commits having one parent or the auto-merge commit
+otherwise.
 
 [[dashboard-endpoints]]
 == Dashboard Endpoints
@@ -3396,6 +3404,8 @@
 |`status`                    ||The HTTP status code for the access.
 200 means success and 403 means denied.
 |`message`                   |optional|A clarifying message if `status` is not 200.
+|`debug_logs`                |optional|
+Debug logs that may help to understand why a permission is denied or allowed.
 |=========================================
 
 [[auto_closeable_changes_check_input]]
@@ -3967,6 +3977,9 @@
 |`copy_all_scores_on_trivial_rebase`|`false` if not set|
 Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
 copyAllScoresOnTrivialRebase] is set on the label.
+|`copy_all_scores_if_list_of_files_did_not_change`|`false` if not set|
+Whether link:config-labels.html#label_copyAllScoresIfListOfFilesDidNotChange[
+copyAllScoresIfListOfFilesDidNotChange] is set on the label.
 |`copy_all_scores_on_merge_first_parent_update`|`false` if not set|
 Whether link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
 copyAllScoresOnMergeFirstParentUpdate] is set on the label.
@@ -4034,6 +4047,9 @@
 |`copy_all_scores_on_trivial_rebase`|optional|
 Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
 copyAllScoresOnTrivialRebase] is set on the label.
+|`copy_all_scores_if_list_of_files_did_not_change`|optional|
+Whether link:config-labels.html#label_copyAllScoresIfListOfFilesDidNotChange[
+copyAllScoresIfListOfFilesDidNotChange] is set on the label.
 |`copy_all_scores_on_merge_first_parent_update`|optional|
 Whether link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
 copyAllScoresOnMergeFirstParentUpdate] is set on the label.
@@ -4197,7 +4213,13 @@
 repository.<name>.defaultSubmitType] is set to a different value.
 |`branches`                  |optional|
 A list of branches that should be initially created. +
-For the branch names the `refs/heads/` prefix can be omitted.
+For the branch names the `refs/heads/` prefix can be omitted. +
+The first entry of the list will be the
+link:project-configuration.html#default-branch[default branch]. +
+If the list is empty, link:config-gerrit.html#gerrit.defaultBranch[host-level default]
+is used. +
+Branches in the Gerrit internal ref space are not allowed, such as
+refs/groups/, refs/changes/, etc...
 |`owners`                    |optional|
 A list of groups that should be assigned as project owner. +
 Each group in the list must be specified as
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 5e2906f..95e1258 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -48,6 +48,7 @@
 * For merged and abandoned changes the owner is added only when a human creates
   an unresolved comment.
 * Only owner, uploader, reviewers and ccs can be in the attention set.
+* The rules for service accounts are different, see link:#bots[Bots].
 
 *!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
@@ -85,7 +86,7 @@
 
 image::images/user-attention-set-reply-select.png["reply dialog section for selecting users", align="center"]
 
-=== Bots
+=== Bots [[bots]]
 
 The attention set is meant for human reviews only. Triggering bots and reacting
 to their results is a different workflow and not in scope of the attenion set.
@@ -145,7 +146,7 @@
 instead.
 
 The "Assignee" feature can be turned on/off with the
-link:config-gerrit.html#change.enableAttentionSet[enableAssignee] config option.
+link:config-gerrit.html#change.enableAssignee[enableAssignee] config option.
 
 === Bold Changes / Mark Reviewed
 
diff --git a/Documentation/user-inline-edit.txt b/Documentation/user-inline-edit.txt
index cd26792..4a9d18f 100644
--- a/Documentation/user-inline-edit.txt
+++ b/Documentation/user-inline-edit.txt
@@ -38,11 +38,11 @@
    *  Select branch for new change: Specify the destination branch of the
       change.
 
-   *  Provide base commit SHA1 for change: Leave this field blank.
+   *  Provide base commit SHA-1 for change: Leave this field blank.
 
 +
-IMPORTANT: Git uses a unique SHA1 value to identify each and every commit (in
-other words, each Git commit generates a new SHA1 hash). This value differs
+IMPORTANT: Git uses a unique SHA-1 value to identify each and every commit (in
+other words, each Git commit generates a new SHA-1 hash). This value differs
 from a Gerrit Change-Id, which is used by Gerrit to uniquely identify a
 change. The Gerrit Change-Id remains static throughout the life of a Gerrit
 change.
diff --git a/Documentation/user-named-destinations.txt b/Documentation/user-named-destinations.txt
index 1b6f143..a1ab258 100644
--- a/Documentation/user-named-destinations.txt
+++ b/Documentation/user-named-destinations.txt
@@ -13,6 +13,7 @@
 row in a destination file represents a single destination in the
 named set.  The left column represents the ref of the destination,
 and the right column represents the project of the destination.
+The named destinations can be publicly accessible by other users.
 
 Example destination file named `destinations/myreviews`:
 
diff --git a/Documentation/user-named-queries.txt b/Documentation/user-named-queries.txt
index e79b3da..c01f790 100644
--- a/Documentation/user-named-queries.txt
+++ b/Documentation/user-named-queries.txt
@@ -7,7 +7,8 @@
 link:intro-user.html#user-refs[user's ref] in the `All-Users` project.  The
 user's queries file is a 2 column tab delimited file.  The left
 column represents the name of the query, and the right column
-represents the query expression represented by the name.
+represents the query expression represented by the name. The named queries
+can be publicly accessible by other users.
 
 Example queries file:
 
diff --git a/Documentation/user-porting-comments.txt b/Documentation/user-porting-comments.txt
new file mode 100644
index 0000000..8b6c005
--- /dev/null
+++ b/Documentation/user-porting-comments.txt
@@ -0,0 +1,37 @@
+#  Porting Comments User Documentation
+
+Report a bug or send feedback using this [Monorail template](https://bugs.chromium.org/p/gerrit/issues/entry?template=Porting+Comments). You can also report a bug through the bug icon in the comment.
+
+Comments in Gerrit are associated with a patchset. When a new patchset is uploaded, the comments are lost since they are not associated with the newer patchset.
+
+image::images/user-porting-comments-original-comment.png["Comment left on Patchset 15", align="center"]
+
+To solve this issue, Gerrit now has “Ported Comments”. These are comments that were left on an older patchset displayed on all the newer patchsets uploaded. For example, a comment left on Patchset 6 will be ported over to Patchset 7, 8 and all subsequent patchsets that are uploaded, not just the latest patchset.
+
+Ported comments are not copies of the comment but the comment simply shown in another place.
+
+Which comments are ported over?
+
+*   Unresolved comments
+*   Unresolved drafts
+*   Resolved drafts
+
+Resolved comments are not ported over.
+
+image::images/user-porting-comments-ported-comment.png["Comment ported over to patchset 16", align="center"]
+
+## Interaction
+
+Ported comments are visually the same as normal comments. They have a link at the top which shows the original patchset of the comment and links to it.
+
+Interacting with the ported comments is exactly the same as interacting with the original comment (again, they are simply the original comment shown in a different location). \
+Marking a ported comment resolved/unresolved will also update the original comment.
+
+
+## Position
+
+Gerrit tries to calculate the position of this comment on the new version of the file and shows the comment on that position for the newer patchset.
+
+It’s not always possible to calculate an appropriate position for a comment. In this case, Gerrit attaches these comments as File Level Comments.
+
+In some exceptional cases (such as the entire file being reverted), there is no appropriate file to associate this comment with. In this case we do not port this comment over. The comment is still present at its original location and visible in the Comments Tab & Change Log.
diff --git a/Documentation/user-privacy.txt b/Documentation/user-privacy.txt
new file mode 100644
index 0000000..d61ee76
--- /dev/null
+++ b/Documentation/user-privacy.txt
@@ -0,0 +1,113 @@
+:linkattrs:
+= Gerrit Code Review - User Privacy
+
+== Purpose
+
+This page documents how Gerrit handles user data.
+
+|===
+| Note: Gerrit has extensive support for link:config-plugins.html[plugins]
+  which extend Gerrits functionality, and these plugins could access, export, or
+  maniuplate user data. This document only focuses on the behavior of Gerrit
+  core and its link:dev-core-plugins.html[core plugins].
+|===
+
+== Types of User Data
+
+Gerrit stores account data required for collaborating on source code changes.
+This data is described by
+link:config-accounts.html#account-data-in-user-branch[Account Data in User
+Branch] and includes link:config-accounts.html#external-ids[External IDs],
+link:config-accounts.html#preferences[User Preferences],
+link:config-accounts.html#project-watches[Project Watches] and personally
+identifiable information, including  name and email address. The email
+address is required to associate Git commits with a Gerrit user account. All
+data except passwords is made accessible to other users who you are visible to,
+as detailed below.
+
+== User Visibility
+
+Gerrit has a concept of link:config-gerrit.html#accounts[account visibility]
+which determines what users a given user can see. This visibility configuration
+applies in account search, reviewer suggestion, and when accessing data through
+the link:rest-api-accounts.html#account-endpoints[Account REST endpoints]. If
+you can see a user, you have read access to most of the
+link:rest-api-accounts.html#account-info[AccountInfo] for that user, including
+name and email address. Additional information, including secondary emails, is
+included in AccountInfo if the caller has “Modify Account” permissions.
+
+Additionally, all users on a change (author, cc’d, reviewer) can see each other,
+irrespective of the  account visibility settings. For example: Say you are a
+reviewer on a change where user Foo is also a reviewer. Even if by account
+visibility you could not search for Foo, you'd still see their avatar, name,
+and email now because you can see the change; this information is required to
+collaborate on a code review. If Foo wasn't on that change, you could not add
+them because reviewer suggestions would not find them due to the account
+visibility settings.
+
+By default, account visibility on a Gerrit instance is set to `ALL` which allows
+all users to be visible to other users, even anonymous (i.e. unauthenticated)
+users. Depending on your installation type, you may want to change this:
+
+* For completely company-internal Gerrit installations (no external users), the
+`ALL` default may make sense.
+
+* If you work with multiple vendors who have
+access to their own independent sets of repos, `VISIBLE_GROUP` may be more
+appropriate as you wouldn’t want vendor A to see accounts from vendor B.
+
+* For public installations, e.g. for open source projects, you may want to
+change this setting or add a notice for users when they create an account e.g.
+“Most of what you submit on this site, including your email address and name,
+will be visible to others who use this service. You may prefer to use an email
+account specifically for this purpose.” One way to do this is using
+link:config-gerrit.html[`auth.registerPageUrl`] in `gerrit.config`.
+
+== ACLs and User Visibility
+
+User suggestions for changes, when adding a reviewer or cc-ing someone, always
+respect ACLs for that change: only users who can see the change are suggested.
+The suggested users are an intersection of who you can see and who can see the
+change.
+
+Consider the following situation:
+
+* `READ` permission for Registered Users on the host
+* User visibility is set to `VISIBILE_GROUP`, so only users of the same domain can
+  see each other
+* a@foo.com creates change 123
+
+This would mean:
+
+* a@foo.com cannot add b@bar.com to the change because these users cannot see
+  each other due to the user visibility setting.
+* b@bar.com can find change 123
+  because they have READ permission and could add themselves to the change.
+* a@foo.com would then be able to see b@bar.com’s name, avatar, and email on
+  change 123
+
+The only caveat to the above are Private Changes, which are only visible to the
+owner and reviewers; reviewers can only see the change once they are added to
+the change (if ACLs allow them to be added in the first place), not before.
+
+## Right to be Forgotten Limitations
+
+As a source control system, Gerrit has limited abilities to remove personally
+identifiable information. Notably, Gerrit cannot:
+
+* Remove a user's e-mail from all existing commits
+* Remove a user's username
+
+There is also a known
+link:https://bugs.chromium.org/p/gerrit/issues/detail?id=14185[bug] where a
+user's username is stored in metadata for link:user-attention-set.html[Attention
+Set].
+
+
+## Open Source Software Limitations
+
+Gerrit is open-source software licensed under the Apache 2.0 license.  Unless
+required by applicable law or agreed to in writing, software distributed under
+the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
+OF ANY KIND, either express or implied. See the License for the specific
+language governing permissions and limitations under the License.
\ No newline at end of file
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 06c5ab7..b33bea8 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -3,8 +3,13 @@
 
 Reviewing changes is an important task and the Gerrit Web UI provides
 many functionalities to make the review process comfortable and
-efficient. This is a guide through the review UI that explains the
-different functions and UI elements.
+efficient.
+
+The UI has three different main views,
+
+** The dashboard, which shows all changes that are relevant to you
+** The change screen, which shows the change with all its metadata
+** The diff view, which shows changes to a single file
 
 [[change-screen]]
 == Change Screen
@@ -14,41 +19,27 @@
 
 image::images/user-review-ui-change-screen.png[width=800, link="images/user-review-ui-change-screen.png"]
 
-[[commit-message]]
-=== Commit Message Block
+Here are the main areas of the screen
 
-The focus of the change screen is on the commit message since this is
-the most important information about a change. The numeric change ID
-and the change status are displayed right above the commit message.
+image::images/user-review-ui-change-screen-annotated.png[width=800, link="images/user-review-ui-change-screen-annotated.png"]
 
-image::images/user-review-ui-change-screen-commit-message.png[width=800, link="images/user-review-ui-change-screen-commit-message.png"]
 
-[[permalink]]
-The numeric change ID is a link to the change and clicking on it
-refreshes the change screen. By copying the link location you can get
-the permalink of the change.
+=== Top info
 
-image::images/user-review-ui-change-screen-permalink.png[width=800, link="images/user-review-ui-change-screen-permalink.png"]
+Top left, you find the status of the change, and a permalink.
+
+image::images/user-review-ui-change-screen-topleft.png[width=400, link="images/user-review-ui-change-screen-topleft.png"]
 
 [[change-status]]
 The change status shows the state of the change:
 
-- [[needs]]`Needs <label>`:
+- [[active]]`Active`:
 +
-The change is in review and an approval on the shown label is still
-required to make the change submittable.
+The change is under active review.
 
-- [[not]]`Not <label>`:
+- [[merge-conflict]]`Merge Conflict`:
 +
-The change is in review and a veto vote on the shown label is
-preventing the submit.
-
-- [[not-current]]`Not Current`:
-+
-The currently viewed patch set is outdated.
-+
-Please note that some operations, like voting, are not available on
-outdated patch sets, but only on the current patch set.
+The change can't be merged due to conflicts.
 
 - [[ready-to-submit]]`Ready to Submit`:
 +
@@ -62,52 +53,28 @@
 +
 The change was abandoned.
 
-[[commit-info]]
-=== Commit Info Block
+[[star]]
+=== Star Change
 
-The commit info block shows information about the commit of the
-currently viewed patch set.
-
-It displays the author and the committer as links to a list of this
-person's changes that have the same status as the currently viewed
-change.
-
-The commit ID, the parent commit(s) and the link:user-changeid.html[Change-Id] are
-displayed with a copy-to-clipboard icon that allows the ID to be copied
-into the clipboard.
-
-If a Git web browser, such as gitweb or Gitiles, is configured, there
-is also a link to the commit in the Git web browser.
-
-image::images/user-review-ui-change-screen-commit-info.png[width=800, link="images/user-review-ui-change-screen-commit-info.png"]
-
-If a merge commit is viewed this is highlighted by an icon.
-
-image::images/user-review-ui-change-screen-commit-info-merge-commit.png[width=800, link="images/user-review-ui-change-screen-commit-info-merge-commit.png"]
+Clicking the star icon marks the change as a favorite: it turns on
+email notifications for this change, and the change is added to the
+list under `Your` > `Starred Changes`. They can be queried by the
+link:user-search.html#is[is:starred] search operator.
 
 [[change-info]]
-=== Change Info Block
+=== Change metadata
 
-The change info block contains detailed information about the change
+The change metadata block contains detailed information about the change
 and offers actions on the change.
 
-image::images/user-review-ui-change-screen-change-info.png[width=800, link="images/user-review-ui-change-screen-change-info.png"]
-
-- [[change-owner]]Change Owner:
-+
-The owner of the change is displayed as a link to a list of the owner's
-changes that have the same status as the currently viewed change.
-+
-image::images/user-review-ui-change-screen-change-info-owner.png[width=800, link="images/user-review-ui-change-screen-change-info-owner.png"]
-
 - [[reviewers]]Reviewers:
 +
-The reviewers of the change are displayed as chip tokens.
+The reviewers of the change are displayed as chips.
 +
 For each reviewer there is a tooltip that shows on which labels the
 reviewer is allowed to vote.
 +
-New reviewers can be added by clicking on the `Add...` button. Typing
+New reviewers can be added by clicking on the pencil icon. Typing
 into the pop-up text field activates auto completion of user and group
 names.
 +
@@ -126,7 +93,7 @@
    and Gerrit administrators may remove anyone.
 
 +
-image::images/user-review-ui-change-screen-change-info-reviewers.png[width=800, link="images/user-review-ui-change-screen-change-info-reviewers.png"]
+image::images/user-review-ui-change-screen-info-reviewers.png[width=600, link="images/user-review-ui-change-screen-reviewers.png"]
 
 - [[project-branch-topic]]Project / Branch / Topic:
 +
@@ -135,9 +102,6 @@
 dashboard] of the project. If no default dashboard is defined, the link
 opens a list of open changes on the project.
 +
-Clicking on the settings icon on the right side navigates to the
-project administration screen.
-+
 The name of the destination branch is displayed as a link to a list
 with all changes on this branch that have the same status as the
 currently viewed change.
@@ -147,28 +111,16 @@
 link:access-control.html#category_edit_topic_name[Edit Topic Name]
 access right. To be able to set a topic on a closed change, the
 `Edit Topic Name` must be assigned with the `force` flag.
-+
-image::images/user-review-ui-change-screen-change-info-project-branch-topic.png[width=800, link="images/user-review-ui-change-screen-change-info-project-branch-topic.png"]
 
 - [[submit-strategy]]Submit Strategy:
 +
 The link:project-setup.html#submit_type[submit strategy] that will be
 used to submit the change. The submit strategy is only displayed for
 open changes.
-+
-image::images/user-review-ui-change-screen-change-info-submit-strategy.png[width=800, link="images/user-review-ui-change-screen-change-info-submit-strategy.png"]
-+
-If a change cannot be merged due to path conflicts this is highlighted
-by a bold red `Cannot Merge` label.
-+
-image::images/user-review-ui-change-screen-change-info-cannot-merge.png[width=800, link="images/user-review-ui-change-screen-change-info-cannot-merge.png"]
-
-- [[update-time]]Time of Last Update:
-+
-image::images/user-review-ui-change-screen-change-info-last-update.png[width=800, link="images/user-review-ui-change-screen-change-info-last-update.png"]
 
 - [[actions]]Actions:
 +
+Actions buttons are at the top, and in the overflow menu.
 Depending on the change state and the permissions of the user, different
 actions are available on the change:
 
@@ -266,13 +218,13 @@
 ** [[plugin-actions]]Further actions may be available if plugins are installed.
 
 +
-image::images/user-review-ui-change-screen-change-info-actions.png[width=800, link="images/user-review-ui-change-screen-change-info-actions.png"]
+image::images/user-review-ui-change-screen-change-info-actions.png[width=600, link="images/user-review-ui-change-screen-change-info-actions.png"]
 
 - [[labels]]Labels & Votes:
 +
-Approving votes are colored green; veto votes are colored red.
+Approving votes are colored green; negative votes are colored red.
 +
-image::images/user-review-ui-change-screen-change-info-labels.png[width=800, link="images/user-review-ui-change-screen-change-info-labels.png"]
+image::images/user-review-ui-change-screen-change-info-labels.png[width=400, link="images/user-review-ui-change-screen-change-info-labels.png"]
 
 [[files]]
 === File List
@@ -299,128 +251,15 @@
 The list of commits that are being integrated into the destination
 branch by submitting the merge commit.
 
-[[change-screen-mark-reviewed]]
-The checkboxes in front of the file names allow files to be marked as reviewed.
-
-image::images/user-review-ui-change-screen-file-list-mark-as-reviewed.png[width=800, link="images/user-review-ui-change-screen-file-list-mark-as-reviewed.png"]
-
-[[modification-type]]
-The type of a file modification is indicated by the character in front
-of the file name:
-
-- `M` or 'no character' (Modified):
-+
-The file existed before this change and is modified.
-
-- `A` (Added):
-+
-The file is newly added.
-
-- `D` (Deleted):
-+
-The file is deleted.
-
-- `R` (Renamed):
-+
-The file is renamed.
-
-- `C` (Copied):
-+
-The file is new and is copied from an existing file.
-
-- `U` (Unchanged):
-+
-The file is unchanged and has the same content. Unchanged files only
-appear in the file list if 2 patch sets are compared and the file has
-comments on at least one of the sides. Otherwise unchanged files are
-filtered out.
-
-image::images/user-review-ui-change-screen-file-list-modification-type.png[width=800, link="images/user-review-ui-change-screen-file-list-modification-type.png"]
-
-[[rename-or-copy]]
-If a file is renamed or copied, the name of the original file is
-displayed in gray below the file name.
-
-image::images/user-review-ui-change-screen-file-list-rename.png[width=800, link="images/user-review-ui-change-screen-file-list-rename.png"]
-
-[[repeating-path-segments]]
-Repeating path segments are grayed out.
-
-image::images/user-review-ui-change-screen-file-list-repeating-paths.png[width=800, link="images/user-review-ui-change-screen-file-list-repeating-paths.png"]
-
-[[inline-comments-column]]
-Inline comments on a file are shown in the `Comments` column.
-
-Draft comments, i.e. comments that have been written by the current
-user but not yet published, are highlighted in red.
-
-New comments from other users, that were published after the current
-user last reviewed this change, are highlighted in bold.
-
-image::images/user-review-ui-change-screen-file-list-comments.png[width=800, link="images/user-review-ui-change-screen-file-list-comments.png"]
-
-[[size]]
-The size of the modifications in the files can be seen in the `Size` column. The
-footer row shows the total size of the change.
-
-The size information is useful to easily spot the files that contain
-the most modifications; these files are likely to be the most relevant
-files for this change. The total change size gives an estimate of how
-long a review of this change may take.
-
-When the "Show Change Sizes As Colored Bars" user preference is enabled, the
-`Size` column shows the sum of inserted and deleted lines as one number.  In
-addition, the change size is shown as a bar. The size of the bar indicates the
-amount of changed lines, and its coloring shows the proportion of insertions
-(green) to deletions (red).
-
-When the "Show Change Sizes As Colored Bars" user preference is disabled, the
-colored bar is not shown.  For added and renamed files, the `Size` column
-shows the number of inserted and deleted lines. For new files, the column only
-shows the total number of lines in the new file. No size is shown for binary
-files and deleted files.
-
-image::images/user-review-ui-change-screen-file-list-size.png[width=800, link="images/user-review-ui-change-screen-file-list-size.png"]
-
-[[diff-against]]
-In the header of the file list, the `Diff Against` selection can be
-changed. This selection allows one to choose if the currently viewed
-patch set should be compared against its base or against another patch
-set of this change. The file list is updated accordingly.
-
-[[open-all]]
-The file list header also provides an `Open All` button that opens the
-diff views for all files in the file list.
-
-image::images/user-review-ui-change-screen-file-list-header.png[width=800, link="images/user-review-ui-change-screen-file-list-header.png"]
-
 [[patch-sets]]
 === Patch Sets
 
 The change screen only presents one patch set at a time. Which patch
 set is currently viewed can be seen from the `Patch Sets` drop-down
-panel in the change header. It shows the number of the currently viewed
-patch set and the total number of patch sets, in the form: "current
-patch set/number of patch sets".
+panel in the change header.
 
-If a non-current patch set is viewed this is indicated by the
-link:#not-current[Not Current] change state. Please note that some
-operations are only available on the current patch set.
+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=800, link="images/user-review-ui-change-screen-patch-sets.png"]
-
-Another indication is a highlighted drop-down label.
-
-image::images/user-review-ui-change-screen-not-current.png[width=800, link="images/user-review-ui-change-screen-not-current.png"]
-
-[[patch-set-drop-down]]
-The patch set drop-down list shows the list of patch sets and allows to
-switch between them. The patch sets are sorted in descending order so
-that the current patch set is always on top.
-
-Draft patch sets are marked with `DRAFT`.
-
-image::images/user-review-ui-change-screen-patch-set-list.png[width=800, link="images/user-review-ui-change-screen-patch-set-list.png"]
 
 [[download]]
 === Download
@@ -455,36 +294,18 @@
 formats (e.g. tar and tbz2); which formats are available depends on the
 configuration of the server.
 
-image::images/user-review-ui-change-screen-download-commands-list.png[width=800, link="images/user-review-ui-change-screen-download-commands-list.png"]
-
 [[included-in]]
 === Included In
 
-For merged changes the `Included In` drop-down panel is available in
-the change header.
+For merged changes the `Included In` drop-down panel is available
+through the overflow menu at the top. It shows the branches and tags
+in which the change is included. E.g. if a change fixes a bug, this
+shows which released versions contain the bug-fix (assuming that every
+release is tagged).
 
 image::images/user-review-ui-change-screen-included-in.png[width=800, link="images/user-review-ui-change-screen-included-in.png"]
 
-The `Included In` drop-down panel shows the branches and tags in which
-the change is included. E.g. if a change fixes a bug, this allows to
-quickly see in which released versions the bug-fix is contained
-(assuming that every release is tagged).
 
-image::images/user-review-ui-change-screen-included-in-list.png[width=800, link="images/user-review-ui-change-screen-included-in-list.png"]
-
-[[star]]
-=== Star Change
-
-The star icon in the change header allows to mark the change as a
-favorite. Clicking on the star icon again, unstars the change.
-
-image::images/user-review-ui-change-screen-star.png[width=800, link="images/user-review-ui-change-screen-star.png"]
-
-Starring a change turns on email notifications for this change.
-
-Starred changed are listed under `My` > `Starred Changes`.
-and can be queried by the link:user-search.html#is[is:starred] search
-operator.
 
 [[related-changes]]
 === Related Changes
@@ -513,29 +334,27 @@
 For merged changes this tab is only shown if there are open
 descendants.
 +
-image::images/user-review-ui-change-screen-related-changes.png[width=800, link="images/user-review-ui-change-screen-related-changes.png"]
-+
-Related changes may be decorated with an icon to signify dependencies
+Related changes may be annotated with dependencies
 on outdated patch sets, or commits that are not associated to changes
 under review:
 +
-** [[outdated]]Orange Dot:
+** [[not-current]]Not current:
 +
 The selected patch set of the change is outdated; it is not the current
 patch set of the change.
 +
-If an ancestor change is marked with an orange dot it means that the
+It means that the
 currently viewed patch set depends on a outdated patch set of the
 ancestor change. This is because a new patch set for the ancestor
 change was uploaded in the meantime and as result the currently viewed
 patch set now needs to be rebased.
 +
-If a descendant change is marked with an orange dot it means that an
+If a descendant change is marked "not current" it means that an
 old patch set of the descendant change depends on the currently viewed
 patch set. It may be that the descendant was rebased in the meantime
 and with the new patch set this dependency was removed.
 
-** [[indirect-descendant]]Green Tilde:
+** [[indirect-descendant]]Indirect descendant:
 +
 The selected patch set of the change is an indirect descendant of the
 currently viewed patch set; it has a dependency to another patch set of
@@ -544,7 +363,7 @@
 note that following the link to an indirect descendant change may
 result in a completely different related changes listing.
 
-** [[closed-ancestor]]Black Dot:
+** [[closed-ancestor]]Closed ancestor:
 +
 Indicates a closed ancestor, e.g. the commit was directly pushed into
 the repository bypassing code review, or the ancestor change was
@@ -552,38 +371,24 @@
 the user has accidentally pushed the commit to the wrong branch, e.g.
 the commit was done on `branch-a`, but was then pushed to
 `refs/for/branch-b`.
-A black dot is also present if the change was abandoned.
 
-** [[closed-ancestor-abandoned]]Strikethrough Subject:
+** [[closed-ancestor-abandoned]]Abandoned:
 +
-When the commit is abandoned, its subject line will be striked
-through.
-
-+
-image::images/user-review-ui-change-screen-related-changes-indicators.png[width=800, link="images/user-review-ui-change-screen-related-changes-indicators.png"]
+Indicates an abandoned change.
 
 - [[conflicts-with]]`Conflicts With`:
 +
-This tab page shows changes that conflict with the current change.
+This section shows changes that conflict with the current change.
 Non-mergeable changes are filtered out; only conflicting changes that
 are mergeable are shown.
 +
 If this change is merged, its conflicting changes will have merge
 conflicts and must be rebased. The rebase of the other changes with the
 conflict resolution must then be done manually.
-+
-image::images/user-review-ui-change-screen-conflicts-with.png[width=800, link="images/user-review-ui-change-screen-conflicts-with.png"]
-
-- [[same-topic]]`Same Topic`:
-+
-This tab page shows changes that have the same topic as the current
-change. Only open changes are included in the list.
-+
-image::images/user-review-ui-change-screen-same-topic.png[width=800, link="images/user-review-ui-change-screen-same-topic.png"]
 
 - [[submitted-together]]`Submitted Together`:
 +
-This tab page shows changes that will be submitted together with the
+This section shows changes that will be submitted together with the
 currently viewed change, when clicking the submit button. It includes
 ancestors of the current patch set.
 +
@@ -594,7 +399,7 @@
 
 - [[cherry-picks]]`Cherry-Picks`:
 +
-This tab page shows changes with the same link:user-changeid.html[
+This section shows changes with the same link:user-changeid.html[
 Change-Id] for the current project.
 +
 Abandoned changes are filtered out.
@@ -602,7 +407,6 @@
 For each change in this list the destination branch is shown as a
 prefix in front of the change subject.
 +
-image::images/user-review-ui-change-screen-cherry-picks.png[width=800, link="images/user-review-ui-change-screen-cherry-picks.png"]
 
 If there are no related changes for a tab, the tab is not displayed.
 
@@ -613,7 +417,7 @@
 currently viewed patch set; one can add a summary comment, publish
 inline draft comments, and vote on the labels.
 
-image::images/user-review-ui-change-screen-reply.png[width=800, link="images/user-review-ui-change-screen-reply.png"]
+image::images/gwt-user-review-ui-change-screen-reply.png[width=800, link="images/gwt-user-review-ui-change-screen-reply.png"]
 
 Clicking on the `Reply...` button opens a popup panel.
 
@@ -639,7 +443,7 @@
 
 The `Post` button publishes the comments and the votes.
 
-image::images/user-review-ui-change-screen-replying.png[width=800, link="images/user-review-ui-change-screen-replying.png"]
+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
@@ -658,7 +462,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/user-review-ui-change-screen-quick-approve.png"]
+image::images/user-review-ui-change-screen-quick-approve.png[width=800, link="images/gwt-user-review-ui-change-screen-quick-approve.png"]
 
 [[history]]
 === History
@@ -672,7 +476,7 @@
 Messages with new comments from other users, that were published after
 the current user last reviewed this change, are automatically expanded.
 
-image::images/user-review-ui-change-screen-history.png[width=800, link="images/user-review-ui-change-screen-history.png"]
+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
@@ -683,13 +487,13 @@
 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/user-review-ui-change-screen-reply-to-comment.png[width=800, link="images/user-review-ui-change-screen-reply-to-comment.png"]
+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/user-review-ui-change-screen-inline-comments.png[width=800, link="images/user-review-ui-change-screen-inline-comments.png"]
+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
@@ -706,7 +510,7 @@
 it is 30 seconds. Polling may also be completely disabled by the
 administrator.
 
-image::images/user-review-ui-change-screen-change-update.png[width=800, link="images/user-review-ui-change-screen-change-update.png"]
+image::images/gwt-user-review-ui-change-screen-change-update.png[width=800, link="images/gwt-user-review-ui-change-screen-change-update.png"]
 
 [[plugin-extensions]]
 === Plugin Extensions
@@ -715,7 +519,7 @@
 additional actions to the change info block and display arbitrary UI
 controls below the change info block.
 
-image::images/user-review-ui-change-screen-plugin-extensions.png[width=800, link="images/user-review-ui-change-screen-plugin-extensions.png"]
+image::images/gwt-user-review-ui-change-screen-plugin-extensions.png[width=800, link="images/gwt-user-review-ui-change-screen-plugin-extensions.png"]
 
 [[side-by-side]]
 == Side-by-Side Diff Screen
@@ -726,7 +530,7 @@
 
 This screen allows to review a patch and to comment on it.
 
-image::images/user-review-ui-side-by-side-diff-screen.png[width=800, link="images/user-review-ui-side-by-side-diff-screen.png"]
+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"]
 
 [[side-by-side-header]]
 In the screen header the project name and the name of the viewed patch
@@ -736,31 +540,15 @@
 the file path are displayed as links to the project and the folder in
 the Git web browser.
 
-image::images/user-review-ui-side-by-side-diff-screen-project-and-file.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-project-and-file.png"]
+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 project name and the file name allows the
+The checkbox in front of the file name allows the
 patch to be marked as reviewed. The link:#mark-reviewed[Mark Reviewed]
 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/user-review-ui-side-by-side-diff-screen-reviewed.png"]
-
-[[scrollbar]]
-The scrollbar shows patch diffs and inline comments as annotations.
-This provides a good overview of the lines in the patch that are
-relevant for reviewing. By clicking on an annotation one can quickly
-navigate to the corresponding line in the patch.
-
-image::images/user-review-ui-side-by-side-diff-screen-scrollbar.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-scrollbar.png"]
-
-[[gaps]]
-A gap between lines in the file content that is caused by aligning the
-left and right side or by displaying inline comments is shown as a
-vertical red bar in the line number column. This prevents a gap from
-being mistaken for blank lines in the file
-
-image::images/user-review-ui-side-by-side-diff-screen-red-bar.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-red-bar.png"]
+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"]
 
 [[patch-set-selection]]
 In the header, on each side, the list of patch sets is shown. Clicking
@@ -780,7 +568,7 @@
 version before, may see what has changed since that version by
 comparing the old patch against the current patch.
 
-image::images/user-review-ui-side-by-side-diff-screen-patch-sets.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-patch-sets.png"]
+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"]
 
 [[download-file]]
 The download icon next to the patch set list allows to download the
@@ -791,14 +579,14 @@
 If the compared patches are identical, this is highlighted by a red
 `No Differences` label in the screen header.
 
-image::images/user-review-ui-side-by-side-diff-screen-no-differences.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-no-differences.png"]
+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/user-review-ui-side-by-side-diff-screen-rename.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-rename.png"]
+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
@@ -807,7 +595,7 @@
 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/user-review-ui-side-by-side-diff-screen-navigation.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-navigation.png"]
+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"]
 
 [[inline-comments]]
 === Inline Comments
@@ -833,7 +621,7 @@
 If the diff preference link:#expand-all-comments[Expand All Comments]
 is set to `Expand`, all inline comments will be automatically expanded.
 
-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"]
+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
@@ -842,7 +630,7 @@
 top left corner. Below the actual comment there are buttons to reply to
 the comment.
 
-image::images/user-review-ui-side-by-side-diff-screen-comment-box.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-comment-box.png"]
+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"]
 
 [[reply-inline-comment]]
 Clicking on the `Reply` button opens an editor to type the reply.
@@ -861,7 +649,7 @@
 
 Clicking on the `Discard` button deletes the inline draft comment.
 
-image::images/user-review-ui-side-by-side-diff-screen-comment-reply.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-comment-reply.png"]
+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
@@ -870,14 +658,14 @@
 A draft comment can be edited by clicking on the `Edit` button, or
 deleted by clicking on the `Discard` button.
 
-image::images/user-review-ui-side-by-side-diff-screen-comment-edit.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-comment-edit.png"]
+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"]
 
 [[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.
 
-image::images/user-review-ui-side-by-side-diff-screen-replied-done.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-replied-done.png"]
+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"]
 
 [[add-inline-comment]]
 To add a new inline comment there are several possibilities:
@@ -903,7 +691,7 @@
 ** press 'V' + arrow keys (or 'j', 'k') to select a code block line-wise
 ** type 'bvw' to select a word
 
-image::images/user-review-ui-side-by-side-diff-screen-comment.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-comment.png"]
+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.
@@ -914,57 +702,15 @@
 
 Clicking on the `Discard` button deletes the new comment.
 
-image::images/user-review-ui-side-by-side-diff-screen-commented.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-commented.png"]
+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"]
 
 [[file-level-comments]]
 === File Level Comments
 
-Comments that apply to a whole file can be added on file level.
+File level comments are added by clicking the 'File' header at the top
+of the file.
 
-File level comments are added by clicking on the comment icon in the
-header above the file.
-
-image::images/user-review-ui-side-by-side-diff-screen-file-level-comment.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-file-level-comment.png"]
-
-Clicking on the comment icon opens a comment box for typing the file
-level comment.
-
-image::images/user-review-ui-side-by-side-diff-screen-file-level-commented.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-file-level-commented.png"]
-
-[[search]]
-=== Search
-
-For searching within a patch file, a Vim-like search is supported.
-Typing `/` opens the search box. Typing in the search box immediately
-highlights matches in the patch file with a yellow background. Using
-JavaScript regular expressions in the search term is supported. The
-search is case insensitive. After confirming the search by `ENTER` one
-can navigate between the matches by `n` / `N` to go to the next /
-previous match. Skipped lines are automatically expanded if they
-contain a match and one navigates to it.
-
-For additional possibilities to search please check the
-link:http://www.vim.org/docs.php[Vim documentation,role=external,window=_blank]. There are other
-useful ways to search, e.g. while the cursor is on a word, pressing `*`
-or `#` searches for the next or previous occurrence of the word.
-
-Searching by `Ctrl-F` finds matches only in the visible area of the
-screen unless the link:#render[Render] diff preference is set to `Slow`.
-
-image::images/user-review-ui-side-by-side-diff-screen-search.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-search.png"]
-
-[[key-navigation]]
-=== Key Navigation
-
-Vim-like commands can be used to navigate within a patch file:
-
-- `h` / `j` / `k` / `l` moves the cursor left / down / up / right
-- `0` / `$` moves the cursor to the start / end of the line
-- `gg` / `G` moves to cursor to the start / end of the file
-- `Ctrl-D` / `Ctrl-U` scrolls downwards / upwards
-
-Please check the link:http://www.vim.org/docs.php[Vim documentation,role=external,window=_blank]
-for further information.
+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"]
 
 [[diff-preferences]]
 === Diff Preferences
@@ -974,27 +720,10 @@
 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/user-review-ui-side-by-side-diff-screen-preferences.png"]
-
-The diff preferences popup allows to change the diff preferences.
-By clicking on the `Save` button changes to the diff preferences are
-saved permanently. Clicking on the `Apply` button applies the new
-diff preferences to the current screen, but they are discarded when the
-screen is refreshed. The `Save` button is only available if the user is
-signed in.
-
-image::images/user-review-ui-side-by-side-diff-screen-preferences-popup.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-preferences-popup.png"]
+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"]
 
 The following diff preferences can be configured:
 
-- [[theme]]`Theme`:
-+
-Controls the theme that is used to render the file content.
-+
-E.g. users could choose to work with a dark theme.
-+
-image::images/user-review-ui-side-by-side-diff-screen-dark-theme.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-dark-theme.png"]
-
 - [[ignore-whitespace]]`Ignore Whitespace`:
 +
 Controls whether differences in whitespace should be ignored or not.
@@ -1003,11 +732,11 @@
 +
 All differences in whitespace are highlighted.
 +
-** `At Line End`:
+** `Trailing`:
 +
 Whitespace differences at the end of lines are ignored.
 +
-** `Leading, At Line End`:
+** `Leading, Trailing`:
 +
 Whitespace differences at the beginning and end of lines are ignored.
 +
@@ -1021,11 +750,7 @@
 
 - [[columns]]`Columns`:
 +
-Sets the preferred line length. At this position a vertical dashed line
-is displayed so that one can easily detect lines the exceed the
-preferred line length.
-+
-image::images/user-review-ui-side-by-side-diff-screen-column.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-column.png"]
+Sets the preferred line length. At this position, lines are wrapped.
 
 - [[lines-of-context]]`Lines Of Context`:
 +
@@ -1042,134 +767,42 @@
 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/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png"]
-
-- [[intraline-difference]]`Intraline Difference`:
-+
-Controls whether intraline differences should be highlighted.
-+
-image::images/user-review-ui-side-by-side-diff-screen-intraline-difference.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-intraline-difference.png"]
+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"]
 
 - [[syntax-highlighting]]`Syntax Highlighting`:
 +
 Controls whether syntax highlighting should be enabled.
 +
 The language for the syntax highlighting is automatically detected from
-the file extension. The language can also be set manually by selecting
-it from the `Language` drop-down list.
-+
-image::images/user-review-ui-side-by-side-diff-screen-syntax-coloring.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-syntax-coloring.png"]
+the file extension.
 
-- [[whitespace-errors]]`Whitespace Errors`:
+- [[whitespace-errors]]`Show trailing whitespace`:
 +
-Controls whether whitespace errors are highlighted.
+Controls whether trailing whitespace is highlighted.
 
 - [[show-tabs]]`Show Tabs`:
 +
 Controls whether tabs are highlighted.
 
-- [[line-numbers]]`Line Numbers`:
-+
-Controls whether line numbers are shown.
-
-- [[empty-pane]]`Empty Pane`:
-+
-Controls whether empty panes are shown or not. The Left pane is empty when a
-file was added; the right pane is empty when a file was deleted.
-
-- [[left-side]]`Left Side`:
-+
-Controls whether the left side is shown. This preference is not
-persistent and is ignored by the `Save` button. Every time a
-patch diff is opened, this preference is reset to `Show`.
-
-- [[top-menu]]`Top Menu`:
-+
-Controls whether the top menu is shown.
-
-- [[auto-hide-diff-table-header]]`Auto Hide Diff Table Header`:
-+
-Controls whether the diff table header should be automatically hidden
-when scrolling down more than half of a page.
-
 - [[mark-reviewed]]`Mark Reviewed`:
 +
 Controls whether the files of the patch set should be automatically
 marked as reviewed when they are viewed.
 
-- [[expand-all-comments]]`Expand All Comments`:
-+
-Controls whether all comments should be automatically expanded.
-
-- [[render]]`Render`:
-+
-Controls how patch files that exceed the screen size are rendered.
-+
-If `Fast` is selected file contents which are outside of the visible
-area are not attached to the browser's DOM tree. This makes the
-rendering fast, but searching by `Ctrl+F` only finds content which is
-in the visible area.
-+
-If `Slow` is selected all file contents are attached to the browser's
-DOM tree, which makes the rendering slow for large files. The advantage
-of this setting is that `Ctrl+F` can be used to search in the complete
-file.
-+
-Large files that exceed 4000 lines will not be fully rendered.
-
-- [[line-wrapping]]`Line Wrapping`:
-+
-Controls whether to enable line wrapping or not.
-+
-If `false` is selected then line wrapping is disabled.
-This is the default option.
-+
-If `true` is selected then line wrapping is enabled.
-
 [[keyboard-shortcuts]]
 == Keyboard Shortcuts
 
 Navigation within the review UI can be completely done by keys, and
 most actions can be controlled by keyboard shortcuts. Typing `?` opens
-a popup that shows a list of available keyboard shortcuts:
+a popup that shows a list of available keyboard shortcuts.
 
-- Change Screen
-+
-image::images/user-review-ui-change-screen-keyboard-shortcuts.png[width=800, link="images/user-review-ui-change-screen-keyboard-shortcuts.png"]
 
-- Side-by-Side Diff Screen
-+
-image::images/user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png"]
-+
+image::images/user-review-ui-change-screen-keyboard-shortcuts.png[width=800, link="images/gwt-user-review-ui-change-screen-keyboard-shortcuts.png"]
+
+
 In addition, Vim-like commands can be used to link:#key-navigation[
 navigate] and link:#search[search] within a patch file.
 
-[[new-vs-old]]
-== New Review UI vs. Old Review UI
-
-There are some important conceptual differences between the old and
-new review UIs:
-
-- The old change screen directly shows all patch sets of the change.
-  With the new change screen only a single patch set is displayed;
-  users can switch between the patch sets by choosing another patch
-  set from the link:#patch-sets[Patch Sets] drop down panel in the
-  screen header.
-- On the old side-by-side diff screen, new comments are inserted by
-  double-clicking on a line. With the new side-by-side diff screen
-  double-click is used to select a word for commenting on it; there
-  are link:#add-inline-comment[several ways to insert new comments],
-  e.g. by selecting a code block and clicking on the popup comment
-  icon.
-
-[[limitations]]
-Limitations of the new review UI:
-
-- The new side-by-side diff screen cannot render images.
-
-- The new side-by-side diff screen isn't able to highlight line
-  endings.
-
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index b22788a..ff1376b 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -35,7 +35,7 @@
 |=============================================================
 
 For change searches (i.e. those using a numerical id, Change-Id, or commit
-SHA1), if the search results in a single change that change will be
+SHA-1), if the search results in a single change that change will be
 presented instead of a list.
 
 For more predictable results, use explicit search operators as described
@@ -92,6 +92,18 @@
 format `2006-01-02[ 15:04:05[.890][ -0700]]`; omitting the time defaults
 to 00:00:00 and omitting the timezone defaults to UTC.
 
+[[mergedbefore]]
+mergedbefore:'TIME'::
++
+Changes merged before the given 'TIME'. The matching behaviour is consistent
+with `before:'TIME'`.
+
+[[mergedafter]]
+mergedafter:'TIME'::
++
+Changes merged after the given 'TIME'. The matching behaviour is consistent
+with `after:'TIME'`.
+
 [[change]]
 change:'ID'::
 +
@@ -106,9 +118,11 @@
 that was scraped out of the commit message.
 
 [[destination]]
-destination:'NAME'::
+destination:'[name=]NAME[,user=USER]'::
 +
-Changes which match the current user's destination named 'NAME'.
+Changes which match the specified USER's destination named 'NAME'. If 'USER'
+is unspecified, the current user is used. The named destinations can be
+publicly accessible by other users.
 (see link:user-named-destinations.html[Named Destinations]).
 
 [[owner]]
@@ -123,9 +137,11 @@
 Changes originally submitted by a user in 'GROUP'.
 
 [[query]]
-query:'NAME'::
+query:'[name=]NAME[,user=USER]'::
 +
-Changes which match the current user's query named 'NAME'
+Changes which match the specified USER's query named 'NAME'. If 'USER'
+is unspecified, the current user is used. The named queries can be
+publicly accessible by other users.
 (see link:user-named-queries.html[Named Queries]).
 
 [[reviewer]]
@@ -157,9 +173,9 @@
 Changes that have been, or need to be, reviewed by a user in 'GROUP'.
 
 [[commit]]
-commit:'SHA1'::
+commit:'SHA-1'::
 +
-Changes where 'SHA1' is one of the patch sets of the change.
+Changes where 'SHA-1' is one of the patch sets of the change.
 
 [[project]]
 project:'PROJECT', p:'PROJECT'::
@@ -174,6 +190,13 @@
 +
 Changes occurring in projects starting with 'PREFIX'.
 
+[[parentof]]
+parentof:'ID'::
+Changes which are parent to the change specified by 'ID'. Change 'ID' can be
+specified as a legacy numerical 'ID' such as 15183, or a Change-Id that can be
+picked from the commit message. This operator will return immediate parents
+and will not return grand parents or higher level ancestors of the given change.
+
 [[parentproject]]
 parentproject:'PROJECT'::
 +
diff --git a/WORKSPACE b/WORKSPACE
index cdec888..b501243 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -30,7 +30,7 @@
 load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "maven_jar")
 load("//plugins:external_plugin_deps.bzl", "external_plugin_deps")
-load("//tools:nongoogle.bzl", "declare_nongoogle_deps")
+load("//tools:nongoogle.bzl", "TESTCONTAINERS_VERSION", "declare_nongoogle_deps")
 
 http_archive(
     name = "platforms",
@@ -43,20 +43,20 @@
 
 http_archive(
     name = "rbe_jdk11",
-    sha256 = "766796de71916118e528b9f4334c29c9c9b4e926227bf3264dee555e6a4306c8",
-    strip_prefix = "rbe_autoconfig-2.0.0",
+    sha256 = "5939e2a4e56d1fc53b6c44c6db97ee068c9f4bd18e86c762f6ab8b4fff5e294b",
+    strip_prefix = "rbe_autoconfig-3.0.0",
     urls = [
-        "https://gerrit-bazel.storage.googleapis.com/rbe_autoconfig/v2.0.0.tar.gz",
-        "https://github.com/davido/rbe_autoconfig/archive/v2.0.0.tar.gz",
+        "https://gerrit-bazel.storage.googleapis.com/rbe_autoconfig/v3.0.0.tar.gz",
+        "https://github.com/davido/rbe_autoconfig/archive/v3.0.0.tar.gz",
     ],
 )
 
 http_archive(
     name = "com_google_protobuf",
-    sha256 = "71030a04aedf9f612d2991c1c552317038c3c5a2b578ac4745267a45e7037c29",
-    strip_prefix = "protobuf-3.12.3",
+    sha256 = "d0f5f605d0d656007ce6c8b5a82df3037e1d8fe8b121ed42e536f569dec16113",
+    strip_prefix = "protobuf-3.14.0",
     urls = [
-        "https://github.com/protocolbuffers/protobuf/archive/v3.12.3.tar.gz",
+        "https://github.com/protocolbuffers/protobuf/archive/v3.14.0.tar.gz",
     ],
 )
 
@@ -103,10 +103,10 @@
 # Golang support for PolyGerrit local dev server.
 http_archive(
     name = "io_bazel_rules_go",
-    sha256 = "a8d6b1b354d371a646d2f7927319974e0f9e52f73a2452d2b3877118169eb6bb",
+    sha256 = "4d838e2d70b955ef9dd0d0648f673141df1bc1d7ecf5c2d621dcc163f47dd38a",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.23.3/rules_go-v0.23.3.tar.gz",
-        "https://github.com/bazelbuild/rules_go/releases/download/v0.23.3/rules_go-v0.23.3.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.24.12/rules_go-v0.24.12.tar.gz",
+        "https://github.com/bazelbuild/rules_go/releases/download/v0.24.12/rules_go-v0.24.12.tar.gz",
     ],
 )
 
@@ -118,10 +118,10 @@
 
 http_archive(
     name = "bazel_gazelle",
-    sha256 = "cdb02a887a7187ea4d5a27452311a75ed8637379a1287d8eeb952138ea485f7d",
+    sha256 = "222e49f034ca7a1d1231422cdb67066b885819885c356673cb1f72f748a3c9d4",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.21.1/bazel-gazelle-v0.21.1.tar.gz",
-        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.21.1/bazel-gazelle-v0.21.1.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.3/bazel-gazelle-v0.22.3.tar.gz",
+        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.3/bazel-gazelle-v0.22.3.tar.gz",
     ],
 )
 
@@ -170,6 +170,12 @@
 )
 
 maven_jar(
+    name = "aopalliance",
+    artifact = "aopalliance:aopalliance:1.0",
+    sha1 = "0235ba8b489512805ac13a8f9ea77a1ca5ebe3e8",
+)
+
+maven_jar(
     name = "javax_inject",
     artifact = "javax.inject:javax.inject:1",
     sha1 = "6975da39a7040257bd51d21a231b76c915872d38",
@@ -181,13 +187,6 @@
     sha1 = "3cd63d075497751784b2fa84be59432f4905bf7c",
 )
 
-# JGit's transitive dependencies
-maven_jar(
-    name = "hamcrest-library",
-    artifact = "org.hamcrest:hamcrest-library:1.3",
-    sha1 = "4785a3c21320980282f9f33d0d1264a69040538f",
-)
-
 maven_jar(
     name = "jzlib",
     artifact = "com.jcraft:jzlib:1.1.1",
@@ -213,14 +212,6 @@
     sha1 = "f645ed69d595b24d4cf8b3fbb64cc505bede8829",
 )
 
-load("//lib:guava.bzl", "GUAVA_BIN_SHA1", "GUAVA_VERSION")
-
-maven_jar(
-    name = "guava",
-    artifact = "com.google.guava:guava:" + GUAVA_VERSION,
-    sha1 = GUAVA_BIN_SHA1,
-)
-
 CAFFEINE_VERS = "2.8.5"
 
 maven_jar(
@@ -369,156 +360,156 @@
     sha1 = "c075db2a3301100cf70c7dced8ecf86b494458a2",
 )
 
-FLEXMARK_VERS = "0.34.18"
+FLEXMARK_VERS = "0.50.42"
 
 maven_jar(
     name = "flexmark",
     artifact = "com.vladsch.flexmark:flexmark:" + FLEXMARK_VERS,
-    sha1 = "65cc1489ef8902023140900a3a7fcce89fba678d",
+    sha1 = "ed537d7bc31883b008cc17d243a691c7efd12a72",
 )
 
 maven_jar(
     name = "flexmark-ext-abbreviation",
     artifact = "com.vladsch.flexmark:flexmark-ext-abbreviation:" + FLEXMARK_VERS,
-    sha1 = "a0384932801e51f16499358dec69a730739aca3f",
+    sha1 = "dc27c3e7abbc8d2cfb154f41c68645c365bb9d22",
 )
 
 maven_jar(
     name = "flexmark-ext-anchorlink",
     artifact = "com.vladsch.flexmark:flexmark-ext-anchorlink:" + FLEXMARK_VERS,
-    sha1 = "6df2e23b5c94a5e46b1956a29179eb783f84ea2f",
+    sha1 = "6a8edb0165f695c9c19b7143a7fbd78c25c3b99c",
 )
 
 maven_jar(
     name = "flexmark-ext-autolink",
     artifact = "com.vladsch.flexmark:flexmark-ext-autolink:" + FLEXMARK_VERS,
-    sha1 = "069f8ff15e5b435cc96b23f31798ce64a7a3f6d3",
+    sha1 = "5da7a4d009ea08ef2d8714cc73e54a992c6d2d9a",
 )
 
 maven_jar(
     name = "flexmark-ext-definition",
     artifact = "com.vladsch.flexmark:flexmark-ext-definition:" + FLEXMARK_VERS,
-    sha1 = "ff177d8970810c05549171e3ce189e2c68b906c0",
+    sha1 = "862d17812654624ed81ce8fc89c5ef819ff45f87",
 )
 
 maven_jar(
     name = "flexmark-ext-emoji",
     artifact = "com.vladsch.flexmark:flexmark-ext-emoji:" + FLEXMARK_VERS,
-    sha1 = "410bf7d8e5b8bc2c4a8cff644d1b2bc7b271a41e",
+    sha1 = "f0d7db64cb546798742b1ffc6db316a33f6acd76",
 )
 
 maven_jar(
     name = "flexmark-ext-escaped-character",
     artifact = "com.vladsch.flexmark:flexmark-ext-escaped-character:" + FLEXMARK_VERS,
-    sha1 = "6f4fb89311b54284a6175341d4a5e280f13b2179",
+    sha1 = "6fd9ab77619df417df949721cb29c45914b326f8",
 )
 
 maven_jar(
     name = "flexmark-ext-footnotes",
     artifact = "com.vladsch.flexmark:flexmark-ext-footnotes:" + FLEXMARK_VERS,
-    sha1 = "35efe7d9aea97b6f36e09c65f748863d14e1cfe4",
+    sha1 = "e36bd69e43147cc6e19c3f55e4b27c0fc5a3d88c",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-issues",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-issues:" + FLEXMARK_VERS,
-    sha1 = "ec1d660102f6a1d0fbe5e57c13b7ff8bae6cff72",
+    sha1 = "5c825dd4e4fa4f7ccbe30dc92d7e35cdcb8a8c24",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-strikethrough",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:" + FLEXMARK_VERS,
-    sha1 = "6060442b742c9b6d4d83d7dd4f0fe477c4686dd2",
+    sha1 = "3256735fd77e7228bf40f7888b4d3dc56787add4",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-tables",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tables:" + FLEXMARK_VERS,
-    sha1 = "2fe597849e46e02e0c1ea1d472848f74ff261282",
+    sha1 = "62f0efcfb974756940ebe749fd4eb01323babc29",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-tasklist",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist:" + FLEXMARK_VERS,
-    sha1 = "b3af19ce4efdc980a066c1bf0f5a6cf8c24c487a",
+    sha1 = "76d4971ad9ce02f0e70351ab6bd06ad8e405e40d",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-users",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-users:" + FLEXMARK_VERS,
-    sha1 = "7456c5f7272c195ee953a02ebab4f58374fb23ee",
+    sha1 = "7b0fc7e42e4da508da167fcf8e1cbf9ba7e21147",
 )
 
 maven_jar(
     name = "flexmark-ext-ins",
     artifact = "com.vladsch.flexmark:flexmark-ext-ins:" + FLEXMARK_VERS,
-    sha1 = "13fe1a95a8f3be30b574451cfe8d3d5936fa3e94",
+    sha1 = "9e51809867b9c4db0fb1c29599b4574e3d2a78e9",
 )
 
 maven_jar(
     name = "flexmark-ext-jekyll-front-matter",
     artifact = "com.vladsch.flexmark:flexmark-ext-jekyll-front-matter:" + FLEXMARK_VERS,
-    sha1 = "e146e2bf3a740d6ef06a33a516c4d1f6d3761109",
+    sha1 = "44eb6dbb33b3831d3b40af938ddcd99c9c16a654",
 )
 
 maven_jar(
     name = "flexmark-ext-superscript",
     artifact = "com.vladsch.flexmark:flexmark-ext-superscript:" + FLEXMARK_VERS,
-    sha1 = "02541211e8e4a6c89ce0a68b07b656d8a19ac282",
+    sha1 = "35815b8cb91000344d1fe5df21cacde8553d2994",
 )
 
 maven_jar(
     name = "flexmark-ext-tables",
     artifact = "com.vladsch.flexmark:flexmark-ext-tables:" + FLEXMARK_VERS,
-    sha1 = "775d9587de71fd50573f32eee98ab039b4dcc219",
+    sha1 = "f6768e98c7210b79d5e8bab76fff27eec6db51e6",
 )
 
 maven_jar(
     name = "flexmark-ext-toc",
     artifact = "com.vladsch.flexmark:flexmark-ext-toc:" + FLEXMARK_VERS,
-    sha1 = "85b75fe1ebe24c92b9d137bcbc51d232845b6077",
+    sha1 = "1968d038fc6c8156f244f5a7eecb34e7e2f33705",
 )
 
 maven_jar(
     name = "flexmark-ext-typographic",
     artifact = "com.vladsch.flexmark:flexmark-ext-typographic:" + FLEXMARK_VERS,
-    sha1 = "c1bf0539de37d83aa05954b442f929e204cd89db",
+    sha1 = "6549b9862b61c4434a855a733237103df9162849",
 )
 
 maven_jar(
     name = "flexmark-ext-wikilink",
     artifact = "com.vladsch.flexmark:flexmark-ext-wikilink:" + FLEXMARK_VERS,
-    sha1 = "400b23b9a4e0c008af0d779f909ee357628be39d",
+    sha1 = "e105b09dd35aab6e6f5c54dfe062ee59bd6f786a",
 )
 
 maven_jar(
     name = "flexmark-ext-yaml-front-matter",
     artifact = "com.vladsch.flexmark:flexmark-ext-yaml-front-matter:" + FLEXMARK_VERS,
-    sha1 = "491f815285a8e16db1e906f3789a94a8a9836fa6",
+    sha1 = "b2d3a1e7f3985841062e8d3203617e29c6c21b52",
 )
 
 maven_jar(
     name = "flexmark-formatter",
     artifact = "com.vladsch.flexmark:flexmark-formatter:" + FLEXMARK_VERS,
-    sha1 = "d46308006800d243727100ca0f17e6837070fd48",
+    sha1 = "a50c6cb10f6d623fc4354a572c583de1372d217f",
 )
 
 maven_jar(
     name = "flexmark-html-parser",
     artifact = "com.vladsch.flexmark:flexmark-html-parser:" + FLEXMARK_VERS,
-    sha1 = "fece2e646d11b6a77fc611b4bd3eb1fb8a635c87",
+    sha1 = "46c075f30017e131c1ada8538f1d8eacf652b044",
 )
 
 maven_jar(
     name = "flexmark-profile-pegdown",
     artifact = "com.vladsch.flexmark:flexmark-profile-pegdown:" + FLEXMARK_VERS,
-    sha1 = "297f723bb51286eaa7029558fac87d819643d577",
+    sha1 = "d9aafd47629959cbeddd731f327ae090fc92b60f",
 )
 
 maven_jar(
     name = "flexmark-util",
     artifact = "com.vladsch.flexmark:flexmark-util:" + FLEXMARK_VERS,
-    sha1 = "31e2e1fbe8273d7c913506eafeb06b1a7badb062",
+    sha1 = "417a9821d5d80ddacbfecadc6843ae7b259d5112",
 )
 
 # Transitive dependency of flexmark and gitiles
@@ -564,36 +555,36 @@
     sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
 )
 
-OW2_VERS = "7.2"
+OW2_VERS = "9.0"
 
 maven_jar(
     name = "ow2-asm",
     artifact = "org.ow2.asm:asm:" + OW2_VERS,
-    sha1 = "fa637eb67eb7628c915d73762b681ae7ff0b9731",
+    sha1 = "af582ff60bc567c42d931500c3fdc20e0141ddf9",
 )
 
 maven_jar(
     name = "ow2-asm-analysis",
     artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
-    sha1 = "b6e6abe057f23630113f4167c34bda7086691258",
+    sha1 = "4630afefbb43939c739445dde0af1a5729a0fb4e",
 )
 
 maven_jar(
     name = "ow2-asm-commons",
     artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
-    sha1 = "ca2954e8d92a05bacc28ff465b25c70e0f512497",
+    sha1 = "5a34a3a9ac44f362f35d1b27932380b0031a3334",
 )
 
 maven_jar(
     name = "ow2-asm-tree",
     artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
-    sha1 = "3a23cc36edaf8fc5a89cb100182758ccb5991487",
+    sha1 = "9df939f25c556b0c7efe00701d47e77a49837f24",
 )
 
 maven_jar(
     name = "ow2-asm-util",
     artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
-    sha1 = "a3ae34e57fa8a4040e28247291d0cc3d6b8c7bcf",
+    sha1 = "7c059a94ab5eed3347bf954e27fab58e52968848",
 )
 
 AUTO_VALUE_VERSION = "1.7.4"
@@ -610,6 +601,38 @@
     sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
 )
 
+AUTO_VALUE_GSON_VERSION = "1.3.0"
+
+maven_jar(
+    name = "auto-value-gson-runtime",
+    artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION,
+    sha1 = "a69a9db5868bb039bd80f60661a771b643eaba59",
+)
+
+maven_jar(
+    name = "auto-value-gson-extension",
+    artifact = "com.ryanharter.auto.value:auto-value-gson-extension:" + AUTO_VALUE_GSON_VERSION,
+    sha1 = "6a61236d17b58b05e32b4c532bcb348280d2212b",
+)
+
+maven_jar(
+    name = "auto-value-gson-factory",
+    artifact = "com.ryanharter.auto.value:auto-value-gson-factory:" + AUTO_VALUE_GSON_VERSION,
+    sha1 = "b1f01918c0d6cb1f5482500e6b9e62589334dbb0",
+)
+
+maven_jar(
+    name = "javapoet",
+    artifact = "com.squareup:javapoet:1.13.0",
+    sha1 = "d6562d385049f35eb50403fa86bb11cce76b866a",
+)
+
+maven_jar(
+    name = "autotransient",
+    artifact = "io.sweers.autotransient:autotransient:1.0.0",
+    sha1 = "38b1c630b8e76560221622289f37be40105abb3d",
+)
+
 declare_nongoogle_deps()
 
 LUCENE_VERS = "6.6.5"
@@ -725,13 +748,6 @@
     sha1 = "b8ba1c1eb8b2e45cfd465d01218c6060e887572e",
 )
 
-# Keep this version of Soy synchronized with the version used in Gitiles.
-maven_jar(
-    name = "soy",
-    artifact = "com.google.template:soy:2019-10-08",
-    sha1 = "4518bf8bac2dbbed684849bc209c39c4cb546237",
-)
-
 maven_jar(
     name = "html-types",
     artifact = "com.google.common.html.types:types:1.0.8",
@@ -800,104 +816,66 @@
 # Test-only dependencies below.
 
 maven_jar(
-    name = "jimfs",
-    artifact = "com.google.jimfs:jimfs:1.1",
-    sha1 = "8fbd0579dc68aba6186935cc1bee21d2f3e7ec1c",
-)
-
-maven_jar(
     name = "junit",
     artifact = "junit:junit:4.12",
     sha1 = "2973d150c0dc1fefe998f834810d68f278ea58ec",
 )
 
 maven_jar(
-    name = "hamcrest-core",
-    artifact = "org.hamcrest:hamcrest-core:1.3",
-    sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
-)
-
-TRUTH_VERS = "1.1"
-
-maven_jar(
-    name = "truth",
-    artifact = "com.google.truth:truth:" + TRUTH_VERS,
-    sha1 = "6a096a16646559c24397b03f797d0c9d75ee8720",
-)
-
-maven_jar(
-    name = "truth-java8-extension",
-    artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
-    sha1 = "258db6eb8df61832c5c059ed2bc2e1c88683e92f",
-)
-
-maven_jar(
-    name = "truth-liteproto-extension",
-    artifact = "com.google.truth.extensions:truth-liteproto-extension:" + TRUTH_VERS,
-    sha1 = "bf65afa13aa03330e739bcaa5d795fe0f10fbf20",
-)
-
-maven_jar(
-    name = "truth-proto-extension",
-    artifact = "com.google.truth.extensions:truth-proto-extension:" + TRUTH_VERS,
-    sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
-)
-
-maven_jar(
     name = "diffutils",
     artifact = "com.googlecode.java-diff-utils:diffutils:1.3.0",
     sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
 )
 
-JETTY_VERS = "9.4.35.v20201120"
+JETTY_VERS = "9.4.36.v20210114"
 
 maven_jar(
     name = "jetty-servlet",
     artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
-    sha1 = "3e61bcb471e1bfc545ce866cbbe33c3aedeec9b1",
+    sha1 = "b189e52a5ee55ae172e4e99e29c5c314f5daf4b9",
 )
 
 maven_jar(
     name = "jetty-security",
     artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
-    sha1 = "80dc2f422789c78315de76d289b7a5b36c3232d5",
+    sha1 = "42030d6ed7dfc0f75818cde0adcf738efc477574",
 )
 
 maven_jar(
     name = "jetty-server",
     artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
-    sha1 = "513502352fd689d4730b2935421b990ada8cc818",
+    sha1 = "88a7d342974aadca658e7386e8d0fcc5c0788f41",
 )
 
 maven_jar(
     name = "jetty-jmx",
     artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
-    sha1 = "38812031940a466d626ab5d9bbbd9d5d39e9f735",
+    sha1 = "bb3847eabe085832aeaedd30e872b40931632e54",
 )
 
 maven_jar(
     name = "jetty-http",
     artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
-    sha1 = "45d35131a35a1e76991682174421e8cdf765fb9f",
+    sha1 = "1eee89a55e04ff94df0f85d95200fc48acb43d86",
 )
 
 maven_jar(
     name = "jetty-io",
     artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
-    sha1 = "eb9460700b99b71ecd82a53697f5ff99f69b9e1c",
+    sha1 = "84a8faf9031eb45a5a2ddb7681e22c483d81ab3a",
 )
 
 maven_jar(
     name = "jetty-util",
     artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
-    sha1 = "ef61b83f9715c3b5355b633d9f01d2834f908ece",
+    sha1 = "925257fbcca6b501a25252c7447dbedb021f7404",
 )
 
 maven_jar(
     name = "jetty-util-ajax",
     artifact = "org.eclipse.jetty:jetty-util-ajax:" + JETTY_VERS,
-    sha1 = "ebbb43912c6423bedb3458e44aee28eeb4d66f27",
-    src_sha1 = "b3acea974a17493afb125a9dfbe783870ce1d2f9",
+    sha1 = "2f478130c21787073facb64d7242e06f94980c60",
+    src_sha1 = "7153d7ca38878d971fd90992c303bb7719ba7a21",
 )
 
 maven_jar(
@@ -920,28 +898,28 @@
 
 maven_jar(
     name = "mockito",
-    artifact = "org.mockito:mockito-core:2.24.0",
-    sha1 = "969a7bcb6f16e076904336ebc7ca171d412cc1f9",
+    artifact = "org.mockito:mockito-core:3.3.3",
+    sha1 = "4878395d4e63173f3825e17e5e0690e8054445f1",
 )
 
-BYTE_BUDDY_VERSION = "1.9.7"
+BYTE_BUDDY_VERSION = "1.10.7"
 
 maven_jar(
     name = "bytebuddy",
     artifact = "net.bytebuddy:byte-buddy:" + BYTE_BUDDY_VERSION,
-    sha1 = "8fea78fea6449e1738b675cb155ce8422661e237",
+    sha1 = "1eefb7dd1b032b33c773ca0a17d5cc9e6b56ea1a",
 )
 
 maven_jar(
     name = "bytebuddy-agent",
     artifact = "net.bytebuddy:byte-buddy-agent:" + BYTE_BUDDY_VERSION,
-    sha1 = "8e7d1b599f4943851ffea125fd9780e572727fc0",
+    sha1 = "c472fad33f617228601172682aa64f8b78508045",
 )
 
 maven_jar(
     name = "objenesis",
-    artifact = "org.objenesis:objenesis:2.6",
-    sha1 = "639033469776fd37c08358c6b92a4761feb2af4b",
+    artifact = "org.objenesis:objenesis:3.0.1",
+    sha1 = "11cfac598df9dc48bb9ed9357ed04212694b7808",
 )
 
 load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
@@ -1190,3 +1168,19 @@
 )
 
 external_plugin_deps()
+
+# When upgrading elasticsearch-rest-client, also upgrade httpcore-nio
+# and httpasyncclient as necessary in tools/nongoogle.bzl. Consider
+# also the other org.apache.httpcomponents dependencies in
+# WORKSPACE.
+maven_jar(
+    name = "elasticsearch-rest-client",
+    artifact = "org.elasticsearch.client:elasticsearch-rest-client:7.8.1",
+    sha1 = "59feefe006a96a39f83b0dfb6780847e06c1d0a8",
+)
+
+maven_jar(
+    name = "testcontainers-elasticsearch",
+    artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
+    sha1 = "6b778a270b7529fcb9b7a6f62f3ae9d38544ce2f",
+)
diff --git a/contrib/populate-fixture-data.py b/contrib/populate-fixture-data.py
index e51e29d..774a382 100755
--- a/contrib/populate-fixture-data.py
+++ b/contrib/populate-fixture-data.py
@@ -157,7 +157,7 @@
         BASE_URL + "groups/?suggest=ad&p=All-Projects",
         headers=HEADERS,
         auth=ADMIN_BASIC_AUTH).text))
-    admin_group_name = r.keys()[0]
+    admin_group_name = list(r.keys())[0]
     GROUP_ADMIN = r[admin_group_name]
     GROUP_ADMIN["name"] = admin_group_name
 
@@ -305,7 +305,7 @@
     project_names = create_gerrit_projects(group_names)
 
     for idx, u in enumerate(gerrit_users):
-        for _ in xrange(random.randint(1, 5)):
-            create_change(u, project_names[4 * idx / len(gerrit_users)])
+        for _ in range(random.randint(1, 5)):
+            create_change(u, project_names[4 * idx // len(gerrit_users)])
 
 main()
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 482c804..426c806 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.acceptance.testsuite.request.SshSessionFactory;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
@@ -59,6 +60,7 @@
 import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
@@ -74,6 +76,7 @@
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.projects.BranchApi;
+import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -114,7 +117,6 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
@@ -143,7 +145,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Provider;
-import com.jcraft.jsch.KeyPair;
 import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
@@ -155,6 +156,7 @@
 import java.nio.file.FileSystems;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.security.KeyPair;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -168,10 +170,13 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -558,7 +563,7 @@
         && (adminSshSession == null || userSshSession == null)) {
       // Create Ssh sessions
       KeyPair adminKeyPair = sshKeys.getKeyPair(admin);
-      GitUtil.initSsh(adminKeyPair);
+      SshSessionFactory.initSsh(adminKeyPair);
       Context ctx = newRequestContext(user);
       atrScope.set(ctx);
       userSshSession = ctx.getSession();
@@ -580,6 +585,7 @@
     ProjectInput in = new ProjectInput();
     TestProjectInput ann = description.getAnnotation(TestProjectInput.class);
     in.name = name("project");
+    in.branches = ImmutableList.of(Constants.R_HEADS + Constants.MASTER);
     if (ann != null) {
       in.parent = Strings.emptyToNull(ann.parent());
       in.description = Strings.emptyToNull(ann.description());
@@ -766,6 +772,57 @@
     return result;
   }
 
+  protected PushOneCommit.Result createNParentsMergeCommitChange(String ref, List<String> fileNames)
+      throws Exception {
+    // This method creates n different commits and creates a merge commit pointing to all n parents.
+    // Each commit will contain all the fileNames. Commit i will have the following file names and
+    // their contents:
+    // {$file_1_name, ${file_1_name}-1}
+    // {$file_2_name, ${file_2_name}-1}, etc...
+    // The merge commit will have:
+    // {$file_1_name, ${file_1_name}-1}
+    // {$file_2_name, ${file_2_name}-2},
+    // {$file_3_name, ${file_3_name}-3}, etc...
+    // i.e. taking the ith file from the ith commit.
+    int n = fileNames.size();
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    List<PushOneCommit.Result> pushResults = new ArrayList<>();
+
+    for (int i = 1; i <= n; i++) {
+      int finalI = i;
+      pushResults.add(
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  testRepo,
+                  "parent " + i,
+                  fileNames.stream().collect(Collectors.toMap(f -> f, f -> f + "-" + finalI)))
+              .to(ref));
+
+      // reset HEAD in order to create a sibling of the first change
+      if (i < n) {
+        testRepo.reset(initial);
+      }
+    }
+
+    PushOneCommit m =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "merge",
+            IntStream.range(1, n + 1)
+                .boxed()
+                .collect(
+                    Collectors.toMap(
+                        i -> fileNames.get(i - 1), i -> fileNames.get(i - 1) + "-" + i)));
+
+    m.setParents(pushResults.stream().map(PushOneCommit.Result::getCommit).collect(toList()));
+    PushOneCommit.Result result = m.to(ref);
+    result.assertOkStatus();
+    return result;
+  }
+
   protected PushOneCommit.Result createCommitAndPush(
       TestRepository<InMemoryRepository> repo,
       String ref,
@@ -1295,6 +1352,16 @@
     assertThat(rule.getMax()).isEqualTo(expectedMax);
   }
 
+  protected void assertHead(String projectName, String expectedRef) throws Exception {
+    // Assert gerrit's project head points to the correct branch
+    assertThat(getProjectBranches(projectName).get(Constants.HEAD).revision)
+        .isEqualTo(RefNames.shortName(expectedRef));
+    // Assert git head points to the correct branch
+    try (Repository repo = repoManager.openRepository(Project.nameKey(projectName))) {
+      assertThat(repo.exactRef(Constants.HEAD).getTarget().getName()).isEqualTo(expectedRef);
+    }
+  }
+
   protected InternalGroup group(AccountGroup.UUID groupUuid) {
     InternalGroup group = groupCache.get(groupUuid).orElse(null);
     assertWithMessage(groupUuid.get()).that(group).isNotNull();
@@ -1568,6 +1635,12 @@
     return comments;
   }
 
+  protected ImmutableMap<String, BranchInfo> getProjectBranches(String projectName)
+      throws RestApiException {
+    return gApi.projects().name(projectName).branches().get().stream()
+        .collect(ImmutableMap.toImmutableMap(branch -> branch.ref, branch -> branch));
+  }
+
   protected AutoCloseable installPlugin(String pluginName, Class<? extends Module> sysModuleClass)
       throws Exception {
     return installPlugin(pluginName, sysModuleClass, null, null);
diff --git a/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java b/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
new file mode 100644
index 0000000..a4ed80a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.CommandModule;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import java.io.BufferedWriter;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.util.Collections;
+import java.util.List;
+
+public class AbstractDynamicOptionsTest extends AbstractDaemonTest {
+  protected static final String LS_SAMPLES = "ls-samples";
+
+  protected interface Bean {
+    void setSamples(List<String> samples);
+  }
+
+  protected static class ListSamples implements Bean, DynamicOptions.BeanReceiver {
+    protected List<String> samples = Collections.emptyList();
+
+    @Override
+    public void setSamples(List<String> samples) {
+      this.samples = samples;
+    }
+
+    public void display(OutputStream displayOutputStream) throws Exception {
+      PrintWriter stdout =
+          new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, "UTF-8")));
+      try {
+        OutputFormat.JSON
+            .newGson()
+            .toJson(samples, new TypeToken<List<String>>() {}.getType(), stdout);
+        stdout.print('\n');
+      } finally {
+        stdout.flush();
+      }
+    }
+
+    @Override
+    public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {}
+  }
+
+  @CommandMetaData(name = LS_SAMPLES, runsAt = MASTER_OR_SLAVE)
+  protected static class ListSamplesCommand extends SshCommand {
+    @Inject private ListSamples impl;
+
+    @Override
+    protected void run() throws Exception {
+      impl.display(out);
+    }
+
+    @Override
+    protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
+      parseCommandLine(impl, pluginOptions);
+    }
+  }
+
+  public static class PluginOneSshModule extends CommandModule {
+    @Override
+    public void configure() {
+      command(LS_SAMPLES).to(ListSamplesCommand.class);
+    }
+  }
+
+  protected static class ListSamplesOptions implements DynamicOptions.BeanParseListener {
+    @Override
+    public void onBeanParseStart(String plugin, Object bean) {
+      ((Bean) bean).setSamples(Lists.newArrayList("sample1", "sample2"));
+    }
+
+    @Override
+    public void onBeanParseEnd(String plugin, Object bean) {}
+  }
+
+  protected static class PluginTwoModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(DynamicOptions.DynamicBean.class)
+          .annotatedWith(
+              Exports.named("com.google.gerrit.acceptance.AbstractDynamicOptionsTest.ListSamples"))
+          .to(ListSamplesOptionsClassNameProvider.class);
+    }
+  }
+
+  protected static class ListSamplesOptionsClassNameProvider
+      implements DynamicOptions.ClassNameProvider {
+    @Override
+    public String getClassName() {
+      return "com.google.gerrit.acceptance.AbstractDynamicOptionsTest$ListSamplesOptions";
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/AbstractLifecycleListenersTest.java b/java/com/google/gerrit/acceptance/AbstractLifecycleListenersTest.java
new file mode 100644
index 0000000..6acf486
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AbstractLifecycleListenersTest.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.gerrit.sshd.commands.Query;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.internal.UniqueAnnotations;
+import java.util.Collections;
+import org.kohsuke.args4j.Option;
+
+public class AbstractLifecycleListenersTest extends AbstractDaemonTest {
+  protected static class SimpleModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
+          .annotatedWith(Exports.named(Query.class))
+          .to(MyClassNameProvider.class);
+      bind(DynamicOptions.DynamicBean.class)
+          .annotatedWith(Exports.named(QueryChanges.class))
+          .to(MyClassNameProvider.class);
+    }
+  }
+
+  protected static class MyClassNameProvider implements DynamicOptions.ModulesClassNamesProvider {
+    @Override
+    public String getClassName() {
+      return "com.google.gerrit.acceptance.AbstractLifecycleListenersTest$MyOptions";
+    }
+
+    @Override
+    public Iterable<String> getModulesClassNames() {
+      return Collections.singleton(
+          "com.google.gerrit.acceptance.AbstractLifecycleListenersTest$MyOptions$MyOptionsModule");
+    }
+  }
+
+  public static class MyOptions implements DynamicOptions.DynamicBean {
+    @Option(name = "--opt")
+    public boolean opt;
+
+    public static class MyOptionsModule extends AbstractModule {
+      @Override
+      protected void configure() {
+        bind(LifecycleListener.class)
+            .annotatedWith(UniqueAnnotations.create())
+            .to(MyLifecycleListener.class);
+      }
+    }
+  }
+
+  protected static class MyLifecycleListener implements LifecycleListener {
+    protected final InvocationCheck invocationCheck;
+
+    @Inject
+    public MyLifecycleListener(InvocationCheck invocationCheck) {
+      this.invocationCheck = invocationCheck;
+    }
+
+    @Override
+    public void start() {
+      invocationCheck.setStartInvoked(true);
+    }
+
+    @Override
+    public void stop() {
+      invocationCheck.setStopInvoked(true);
+    }
+  }
+
+  @Singleton
+  public static class InvocationCheck {
+    private boolean isStartInvoked = false;
+    private boolean isStopInvoked = false;
+
+    public boolean isStartInvoked() {
+      return isStartInvoked;
+    }
+
+    public void setStartInvoked(boolean startInvoked) {
+      isStartInvoked = startInvoked;
+    }
+
+    public boolean isStopInvoked() {
+      return isStopInvoked;
+    }
+
+    public void setStopInvoked(boolean stopInvoked) {
+      isStopInvoked = stopInvoked;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
index a91bc49..91fbf9e 100644
--- a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.GetChange;
@@ -40,7 +39,6 @@
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
-import com.google.inject.Module;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
@@ -86,21 +84,6 @@
     }
   }
 
-  protected static class NullAttributeModule extends AbstractModule {
-    @Override
-    public void configure() {
-      DynamicSet.bind(binder(), ChangeAttributeFactory.class).toInstance((cd, bp, p) -> null);
-    }
-  }
-
-  protected static class SimpleAttributeModule extends AbstractModule {
-    @Override
-    public void configure() {
-      DynamicSet.bind(binder(), ChangeAttributeFactory.class)
-          .toInstance((cd, bp, p) -> new MyInfo("change " + cd.getId()));
-    }
-  }
-
   protected static class PluginDefinedSimpleAttributeModule extends AbstractModule {
     @Override
     public void configure() {
@@ -170,21 +153,6 @@
     private String opt;
   }
 
-  protected static class OptionAttributeModule extends AbstractModule {
-    @Override
-    public void configure() {
-      DynamicSet.bind(binder(), ChangeAttributeFactory.class)
-          .toInstance(
-              (cd, bp, p) -> {
-                MyOptions opts = (MyOptions) bp.getDynamicBean(p);
-                return opts != null ? new MyInfo("opt " + opts.opt) : null;
-              });
-      bind(DynamicBean.class).annotatedWith(Exports.named(Query.class)).to(MyOptions.class);
-      bind(DynamicBean.class).annotatedWith(Exports.named(QueryChanges.class)).to(MyOptions.class);
-      bind(DynamicBean.class).annotatedWith(Exports.named(GetChange.class)).to(MyOptions.class);
-    }
-  }
-
   public static class BulkAttributeFactoryWithOption implements ChangePluginDefinedInfoFactory {
     protected MyOptions opts;
 
@@ -211,33 +179,6 @@
     }
   }
 
-  protected void getChangeWithNullAttribute(PluginInfoGetter getter) throws Exception {
-    Change.Id id = createChange().getChange().getId();
-    assertThat(getter.call(id)).isNull();
-
-    try (AutoCloseable ignored = installPlugin("my-plugin", NullAttributeModule.class)) {
-      assertThat(getter.call(id)).isNull();
-    }
-
-    assertThat(getter.call(id)).isNull();
-  }
-
-  protected void getChangeWithSimpleAttribute(PluginInfoGetter getter) throws Exception {
-    getChangeWithSimpleAttribute(getter, SimpleAttributeModule.class);
-  }
-
-  protected void getChangeWithSimpleAttribute(
-      PluginInfoGetter getter, Class<? extends Module> moduleClass) throws Exception {
-    Change.Id id = createChange().getChange().getId();
-    assertThat(getter.call(id)).isNull();
-
-    try (AutoCloseable ignored = installPlugin("my-plugin", moduleClass)) {
-      assertThat(getter.call(id)).containsExactly(new MyInfo("my-plugin", "change " + id));
-    }
-
-    assertThat(getter.call(id)).isNull();
-  }
-
   protected void getSingleChangeWithPluginDefinedBulkAttribute(BulkPluginInfoGetterWithId getter)
       throws Exception {
     Change.Id id = createChange().getChange().getId();
@@ -298,30 +239,6 @@
     assertThat(pluginInfos.get(changeWithInfo)).isNull();
   }
 
-  protected void getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
-      BulkPluginInfoGetter getter) throws Exception {
-    Change.Id id1 = createChange().getChange().getId();
-    Change.Id id2 = createChange().getChange().getId();
-
-    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
-    assertThat(pluginInfos.get(id1)).isNull();
-    assertThat(pluginInfos.get(id2)).isNull();
-
-    try (AutoCloseable ignored =
-            installPlugin("my-plugin-1", PluginDefinedSimpleAttributeModule.class);
-        AutoCloseable ignored1 = installPlugin("my-plugin-2", SimpleAttributeModule.class)) {
-      pluginInfos = getter.call();
-      assertThat(pluginInfos.get(id1)).contains(new MyInfo("my-plugin-1", "change " + id1));
-      assertThat(pluginInfos.get(id1)).contains(new MyInfo("my-plugin-2", "change " + id1));
-      assertThat(pluginInfos.get(id2)).contains(new MyInfo("my-plugin-1", "change " + id2));
-      assertThat(pluginInfos.get(id2)).contains(new MyInfo("my-plugin-2", "change " + id2));
-    }
-
-    pluginInfos = getter.call();
-    assertThat(pluginInfos.get(id1)).isNull();
-    assertThat(pluginInfos.get(id2)).isNull();
-  }
-
   protected void getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
       BulkPluginInfoGetter getter) throws Exception {
     Change.Id id1 = createChange().getChange().getId();
@@ -345,22 +262,6 @@
     assertThat(pluginInfos.get(id2)).isNull();
   }
 
-  protected void getChangeWithOption(
-      PluginInfoGetter getterWithoutOptions, PluginInfoGetterWithOptions getterWithOptions)
-      throws Exception {
-    Change.Id id = createChange().getChange().getId();
-    assertThat(getterWithoutOptions.call(id)).isNull();
-
-    try (AutoCloseable ignored = installPlugin("my-plugin", OptionAttributeModule.class)) {
-      assertThat(getterWithoutOptions.call(id))
-          .containsExactly(new MyInfo("my-plugin", "opt null"));
-      assertThat(getterWithOptions.call(id, ImmutableListMultimap.of("my-plugin--opt", "foo")))
-          .containsExactly(new MyInfo("my-plugin", "opt foo"));
-    }
-
-    assertThat(getterWithoutOptions.call(id)).isNull();
-  }
-
   protected void getChangeWithPluginDefinedBulkAttributeOption(
       BulkPluginInfoGetterWithId getterWithoutOptions,
       BulkPluginInfoGetterWithIdAndOptions getterWithOptions)
@@ -387,7 +288,6 @@
 
     try (AutoCloseable ignored =
         installPlugin("my-plugin", PluginDefinedBulkExceptionModule.class)) {
-      PluginDefinedInfo errorInfo = new PluginDefinedInfo();
       List<PluginDefinedInfo> outputInfos = getter.call(id).get(id);
       assertThat(outputInfos).hasSize(1);
       assertThat(outputInfos.get(0).name).isEqualTo("my-plugin");
@@ -455,11 +355,6 @@
   }
 
   @FunctionalInterface
-  protected interface PluginInfoGetter {
-    List<PluginDefinedInfo> call(Change.Id id) throws Exception;
-  }
-
-  @FunctionalInterface
   protected interface BulkPluginInfoGetter {
     Map<Change.Id, List<PluginDefinedInfo>> call() throws Exception;
   }
@@ -474,10 +369,4 @@
     Map<Change.Id, List<PluginDefinedInfo>> call(
         Change.Id id, ImmutableListMultimap<String, String> pluginOptions) throws Exception;
   }
-
-  @FunctionalInterface
-  protected interface PluginInfoGetterWithOptions {
-    List<PluginDefinedInfo> call(Change.Id id, ImmutableListMultimap<String, String> pluginOptions)
-        throws Exception;
-  }
 }
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java b/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java
new file mode 100644
index 0000000..60def29
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java
@@ -0,0 +1,112 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.systemstatus.ServerInformation;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.util.PluginLogFile;
+import com.google.gerrit.server.util.SystemLog;
+import com.google.gerrit.sshd.commands.Query;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.internal.UniqueAnnotations;
+import java.util.Collections;
+import org.apache.log4j.AsyncAppender;
+import org.apache.log4j.Layout;
+import org.apache.log4j.PatternLayout;
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
+
+public class AbstractPluginLogFileTest extends AbstractDaemonTest {
+  protected static class Module extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
+          .annotatedWith(Exports.named(Query.class))
+          .to(MyClassNameProvider.class);
+    }
+  }
+
+  protected static class MyClassNameProvider implements DynamicOptions.ModulesClassNamesProvider {
+    @Override
+    public String getClassName() {
+      return "com.google.gerrit.acceptance.AbstractPluginLogFileTest$MyOptions";
+    }
+
+    @Override
+    public Iterable<String> getModulesClassNames() {
+      return Collections.singleton(
+          "com.google.gerrit.acceptance.AbstractPluginLogFileTest$MyOptions$MyOptionsModule");
+    }
+  }
+
+  public static class MyOptions implements DynamicOptions.DynamicBean {
+    @Option(name = "--opt")
+    public boolean opt;
+
+    public static class MyOptionsModule extends AbstractModule {
+      @Override
+      protected void configure() {
+        bind(LifecycleListener.class)
+            .annotatedWith(UniqueAnnotations.create())
+            .to(MyPluginLogFile.class);
+      }
+    }
+  }
+
+  protected static class MyPluginLogFile extends PluginLogFile {
+    protected static final String logName = "test_log";
+
+    @Inject
+    public MyPluginLogFile(MySystemLog mySystemLog, ServerInformation serverInfo) {
+      super(mySystemLog, serverInfo, logName, new PatternLayout("[%d] [%t] %m%n"));
+    }
+  }
+
+  @Singleton
+  protected static class MySystemLog extends SystemLog {
+    protected InvocationCounter invocationCounter;
+
+    @Inject
+    public MySystemLog(SitePaths site, Config config, InvocationCounter invocationCounter) {
+      super(site, config);
+      this.invocationCounter = invocationCounter;
+    }
+
+    @Override
+    public AsyncAppender createAsyncAppender(
+        String name, Layout layout, boolean rotate, boolean forPlugin) {
+      invocationCounter.increment();
+      return super.createAsyncAppender(name, layout, rotate, forPlugin);
+    }
+  }
+
+  @Singleton
+  public static class InvocationCounter {
+    private int counter = 0;
+
+    public int getCounter() {
+      return counter;
+    }
+
+    public synchronized void increment() {
+      counter++;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/AbstractPredicateTest.java b/java/com/google/gerrit/acceptance/AbstractPredicateTest.java
index c629959..c8bdb63 100644
--- a/java/com/google/gerrit/acceptance/AbstractPredicateTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPredicateTest.java
@@ -17,13 +17,14 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.DynamicOptions;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.restapi.change.QueryChanges;
@@ -33,8 +34,11 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import org.kohsuke.args4j.Option;
 
 public abstract class AbstractPredicateTest extends AbstractDaemonTest {
@@ -50,7 +54,7 @@
       bind(DynamicOptions.DynamicBean.class)
           .annotatedWith(Exports.named(QueryChanges.class))
           .to(MyQueryOptions.class);
-      bind(ChangeAttributeFactory.class)
+      bind(ChangePluginDefinedInfoFactory.class)
           .annotatedWith(Exports.named("sample"))
           .to(AttributeFactory.class);
     }
@@ -61,7 +65,7 @@
     public boolean sample;
   }
 
-  protected static class AttributeFactory implements ChangeAttributeFactory {
+  protected static class AttributeFactory implements ChangePluginDefinedInfoFactory {
     private final Provider<ChangeQueryBuilder> queryBuilderProvider;
 
     @Inject
@@ -70,23 +74,27 @@
     }
 
     @Override
-    public PluginDefinedInfo create(
-        ChangeData cd, DynamicOptions.BeanProvider beanProvider, String plugin) {
+    public Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+        Collection<ChangeData> cds, DynamicOptions.BeanProvider beanProvider, String plugin) {
       MyQueryOptions options = (MyQueryOptions) beanProvider.getDynamicBean(plugin);
-      PluginDefinedInfo myInfo = new PluginDefinedInfo();
+      Map<Change.Id, PluginDefinedInfo> res = new HashMap<>();
       if (options.sample) {
         try {
           Predicate<ChangeData> predicate = queryBuilderProvider.get().parse("label:Code-Review+2");
-          if (predicate.isMatchable() && predicate.asMatchable().match(cd)) {
-            myInfo.message = "matched";
-          } else {
-            myInfo.message = "not matched";
+          for (ChangeData cd : cds) {
+            PluginDefinedInfo myInfo = new PluginDefinedInfo();
+            if (predicate.isMatchable() && predicate.asMatchable().match(cd)) {
+              myInfo.message = "matched";
+            } else {
+              myInfo.message = "not matched";
+            }
+            res.put(cd.getId(), myInfo);
           }
         } catch (QueryParseException e) {
           // ignored
         }
       }
-      return myInfo;
+      return res;
     }
   }
 
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 3ab1cec..1b0954e 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -22,12 +22,13 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.notedb.Sequences;
@@ -110,7 +111,7 @@
           throw new NoSuchGroupException(n);
         }
         addGroupMember(group.get().getGroupUUID(), id);
-        if ("Service Users".equals(n)) {
+        if (ServiceUserClassifier.SERVICE_USERS.equals(n)) {
           tags.add("SERVICE_USER");
         }
       }
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index db0dc84..5ee1a08 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -40,6 +40,7 @@
     "//lib:guava-retrying",
     "//lib:jgit",
     "//lib:jgit-ssh-jsch",
+    "//lib:jgit-ssh-apache",
     "//lib:jsch",
     "//lib/commons:compress",
     "//lib/commons:lang",
@@ -47,10 +48,12 @@
     "//lib/guice",
     "//lib/guice:guice-assistedinject",
     "//lib/guice:guice-servlet",
+    "//lib/log:log4j",
     "//lib/mail",
     "//lib/mina:sshd",
     "//lib:guava",
     "//lib/bouncycastle:bcpg",
+    "//lib/bouncycastle:bcpkix",
     "//lib/bouncycastle:bcprov",
     "//prolog:gerrit-prolog-common",
 ]
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index a5d8d19..d72ee3f 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -26,22 +26,26 @@
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
 import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 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.server.ExceptionHook;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.change.ChangeETagComputation;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.gerrit.server.git.receive.PluginPushOption;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
 import com.google.gerrit.server.git.validators.RefOperationValidationListener;
 import com.google.gerrit.server.logging.PerformanceLogger;
+import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.validators.AccountActivationValidationListener;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
@@ -58,6 +62,7 @@
   private final DynamicSet<GroupIndexedListener> groupIndexedListeners;
   private final DynamicSet<ProjectIndexedListener> projectIndexedListeners;
   private final DynamicSet<CommitValidationListener> commitValidationListeners;
+  private final DynamicSet<TopicEditedListener> topicEditedListeners;
   private final DynamicSet<ExceptionHook> exceptionHooks;
   private final DynamicSet<PerformanceLogger> performanceLoggers;
   private final DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
@@ -71,6 +76,7 @@
   private final DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
   private final DynamicSet<FileHistoryWebLink> fileHistoryWebLinks;
   private final DynamicSet<PatchSetWebLink> patchSetWebLinks;
+  private final DynamicSet<FileWebLink> fileWebLinks;
   private final DynamicSet<RevisionCreatedListener> revisionCreatedListeners;
   private final DynamicSet<GroupBackend> groupBackends;
   private final DynamicSet<AccountActivationValidationListener>
@@ -81,6 +87,8 @@
   private final DynamicMap<CapabilityDefinition> capabilityDefinitions;
   private final DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+  private final DynamicSet<PluginPushOption> pluginPushOptions;
+  private final DynamicSet<OnPostReview> onPostReviews;
 
   @Inject
   ExtensionRegistry(
@@ -89,6 +97,7 @@
       DynamicSet<GroupIndexedListener> groupIndexedListeners,
       DynamicSet<ProjectIndexedListener> projectIndexedListeners,
       DynamicSet<CommitValidationListener> commitValidationListeners,
+      DynamicSet<TopicEditedListener> topicEditedListeners,
       DynamicSet<ExceptionHook> exceptionHooks,
       DynamicSet<PerformanceLogger> performanceLoggers,
       DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
@@ -102,6 +111,7 @@
       DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners,
       DynamicSet<FileHistoryWebLink> fileHistoryWebLinks,
       DynamicSet<PatchSetWebLink> patchSetWebLinks,
+      DynamicSet<FileWebLink> fileWebLinks,
       DynamicSet<RevisionCreatedListener> revisionCreatedListeners,
       DynamicSet<GroupBackend> groupBackends,
       DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners,
@@ -110,12 +120,15 @@
       DynamicSet<WorkInProgressStateChangedListener> workInProgressStateChangedListeners,
       DynamicMap<CapabilityDefinition> capabilityDefinitions,
       DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions,
-      DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
+      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      DynamicSet<PluginPushOption> pluginPushOption,
+      DynamicSet<OnPostReview> onPostReviews) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
     this.groupIndexedListeners = groupIndexedListeners;
     this.projectIndexedListeners = projectIndexedListeners;
     this.commitValidationListeners = commitValidationListeners;
+    this.topicEditedListeners = topicEditedListeners;
     this.exceptionHooks = exceptionHooks;
     this.performanceLoggers = performanceLoggers;
     this.projectCreationValidationListeners = projectCreationValidationListeners;
@@ -129,6 +142,7 @@
     this.refUpdatedListeners = refUpdatedListeners;
     this.fileHistoryWebLinks = fileHistoryWebLinks;
     this.patchSetWebLinks = patchSetWebLinks;
+    this.fileWebLinks = fileWebLinks;
     this.revisionCreatedListeners = revisionCreatedListeners;
     this.groupBackends = groupBackends;
     this.accountActivationValidationListeners = accountActivationValidationListeners;
@@ -138,6 +152,8 @@
     this.capabilityDefinitions = capabilityDefinitions;
     this.pluginProjectPermissionDefinitions = pluginProjectPermissionDefinitions;
     this.pluginConfigEntries = pluginConfigEntries;
+    this.pluginPushOptions = pluginPushOption;
+    this.onPostReviews = onPostReviews;
   }
 
   public Registration newRegistration() {
@@ -168,6 +184,10 @@
       return add(commitValidationListeners, commitValidationListener);
     }
 
+    public Registration add(TopicEditedListener topicEditedListener) {
+      return add(topicEditedListeners, topicEditedListener);
+    }
+
     public Registration add(ExceptionHook exceptionHook) {
       return add(exceptionHooks, exceptionHook);
     }
@@ -224,6 +244,10 @@
       return add(patchSetWebLinks, patchSetWebLink);
     }
 
+    public Registration add(FileWebLink fileWebLink) {
+      return add(fileWebLinks, fileWebLink);
+    }
+
     public Registration add(RevisionCreatedListener revisionCreatedListener) {
       return add(revisionCreatedListeners, revisionCreatedListener);
     }
@@ -262,6 +286,14 @@
       return add(pluginConfigEntries, pluginConfigEntry, exportName);
     }
 
+    public Registration add(PluginPushOption pluginPushOption) {
+      return add(pluginPushOptions, pluginPushOption);
+    }
+
+    public Registration add(OnPostReview onPostReview) {
+      return add(onPostReviews, onPostReview);
+    }
+
     private <T> Registration add(DynamicSet<T> dynamicSet, T extension) {
       return add(dynamicSet, extension, "gerrit");
     }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index cfc0ce4..d1e12f8 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.server.config.GerritRuntime;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.experiments.ConfigExperimentFeatures;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
 import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
@@ -439,7 +440,8 @@
               protected void configure() {
                 bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
               }
-            }));
+            },
+            new ConfigExperimentFeatures.Module()));
     daemon.addAdditionalSysModuleForTesting(
         new ReindexProjectsAtStartup.Module(), new ReindexGroupsAtStartup.Module());
     daemon.start();
@@ -592,7 +594,7 @@
     httpAddress = new InetSocketAddress(uri.getHost(), uri.getPort());
   }
 
-  String getUrl() {
+  public String getUrl() {
     return url;
   }
 
diff --git a/java/com/google/gerrit/acceptance/GitUtil.java b/java/com/google/gerrit/acceptance/GitUtil.java
index ae72793..94d329d 100644
--- a/java/com/google/gerrit/acceptance/GitUtil.java
+++ b/java/com/google/gerrit/acceptance/GitUtil.java
@@ -20,17 +20,11 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
-import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.entities.Project;
-import com.jcraft.jsch.JSch;
-import com.jcraft.jsch.JSchException;
-import com.jcraft.jsch.KeyPair;
-import com.jcraft.jsch.Session;
 import java.io.IOException;
 import java.util.List;
 import java.util.Optional;
-import java.util.Properties;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.api.FetchCommand;
@@ -47,41 +41,15 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.FetchResult;
-import org.eclipse.jgit.transport.JschConfigSessionFactory;
-import org.eclipse.jgit.transport.OpenSshConfig.Host;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
-import org.eclipse.jgit.transport.SshSessionFactory;
 import org.eclipse.jgit.util.FS;
 
 public class GitUtil {
   private static final AtomicInteger testRepoCount = new AtomicInteger();
   private static final int TEST_REPO_WINDOW_DAYS = 2;
 
-  public static void initSsh(KeyPair keyPair) {
-    final Properties config = new Properties();
-    config.put("StrictHostKeyChecking", "no");
-    JSch.setConfig(config);
-
-    // register a JschConfigSessionFactory that adds the private key as identity
-    // to the JSch instance of JGit so that SSH communication via JGit can
-    // succeed
-    SshSessionFactory.setInstance(
-        new JschConfigSessionFactory() {
-          @Override
-          protected void configure(Host hc, Session session) {
-            try {
-              final JSch jsch = getJSch(hc, FS.DETECTED);
-              jsch.addIdentity(
-                  "KeyPair", TestSshKeys.privateKey(keyPair), keyPair.getPublicKeyBlob(), null);
-            } catch (JSchException e) {
-              throw new RuntimeException(e);
-            }
-          }
-        });
-  }
-
   /**
    * Create a new {@link TestRepository} with a distinct commit clock.
    *
diff --git a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index a3207e2..de9a43d 100644
--- a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePath;
@@ -64,7 +63,7 @@
       bind(InMemoryRepositoryManager.class).in(SINGLETON);
     }
 
-    bind(MetricMaker.class).to(DisabledMetricMaker.class);
+    bind(MetricMaker.class).to(TestMetricMaker.class);
 
     listener().to(CreateSchema.class);
 
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index afd451a..67e26ec 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -43,8 +43,13 @@
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.api.TagCommand;
+import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
+import org.eclipse.jgit.dircache.DirCacheEntry;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevBlob;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
@@ -277,6 +282,36 @@
     return this;
   }
 
+  public PushOneCommit addSymlink(String path, String target) throws Exception {
+    RevBlob blobId = testRepo.blob(target);
+    commitBuilder.edit(
+        new PathEdit(path) {
+          @Override
+          public void apply(DirCacheEntry ent) {
+            ent.setFileMode(FileMode.SYMLINK);
+            ent.setObjectId(blobId);
+          }
+        });
+    return this;
+  }
+
+  public PushOneCommit addGitSubmodule(String modulePath, ObjectId commitId) {
+    commitBuilder.edit(
+        new PathEdit(modulePath) {
+          @Override
+          public void apply(DirCacheEntry ent) {
+            ent.setFileMode(FileMode.GITLINK);
+            ent.setObjectId(commitId);
+          }
+        });
+    return this;
+  }
+
+  public PushOneCommit rmFile(String filename) {
+    commitBuilder.rm(filename);
+    return this;
+  }
+
   public Result to(String ref) throws Exception {
     for (Map.Entry<String, String> e : files.entrySet()) {
       commitBuilder.add(e.getKey(), e.getValue());
diff --git a/java/com/google/gerrit/acceptance/SshSession.java b/java/com/google/gerrit/acceptance/SshSession.java
index 21b216a..23bfd0b 100644
--- a/java/com/google/gerrit/acceptance/SshSession.java
+++ b/java/com/google/gerrit/acceptance/SshSession.java
@@ -14,31 +14,19 @@
 
 package com.google.gerrit.acceptance;
 
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
-import com.jcraft.jsch.ChannelExec;
-import com.jcraft.jsch.JSch;
-import com.jcraft.jsch.KeyPair;
-import com.jcraft.jsch.Session;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
 import java.io.Reader;
 import java.net.InetSocketAddress;
-import java.nio.charset.StandardCharsets;
-import java.util.Scanner;
 
-public class SshSession {
-  private final TestSshKeys sshKeys;
-  private final InetSocketAddress addr;
-  private final TestAccount account;
-  private Session session;
-  private String error;
+public abstract class SshSession {
+  protected final TestSshKeys sshKeys;
+  protected final InetSocketAddress addr;
+  protected final TestAccount account;
+  protected String error;
 
   public SshSession(TestSshKeys sshKeys, InetSocketAddress addr, TestAccount account) {
     this.sshKeys = sshKeys;
@@ -46,58 +34,15 @@
     this.account = account;
   }
 
-  public void open() throws Exception {
-    getSession();
-  }
+  public abstract void open() throws Exception;
 
-  @SuppressWarnings("resource")
-  public String exec(String command) throws Exception {
-    ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
-    try {
-      channel.setCommand(command);
-      InputStream in = channel.getInputStream();
-      InputStream err = channel.getErrStream();
-      channel.connect();
+  public abstract void close();
 
-      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
-      error = s.hasNext() ? s.next() : null;
+  public abstract String exec(String command) throws Exception;
 
-      s = new Scanner(in, UTF_8.name()).useDelimiter("\\A");
-      return s.hasNext() ? s.next() : "";
-    } finally {
-      channel.disconnect();
-    }
-  }
+  public abstract int execAndReturnStatus(String command) throws Exception;
 
-  @SuppressWarnings("resource")
-  public int execAndReturnStatus(String command) throws Exception {
-    ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
-    try {
-      channel.setCommand(command);
-      InputStream err = channel.getErrStream();
-      channel.connect();
-
-      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
-      error = s.hasNext() ? s.next() : null;
-      return channel.getExitStatus();
-    } finally {
-      channel.disconnect();
-    }
-  }
-
-  public Reader execAndReturnReader(String command) throws Exception {
-    ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
-    channel.setCommand(command);
-    channel.connect();
-
-    return new InputStreamReader(channel.getInputStream(), StandardCharsets.UTF_8) {
-      @Override
-      public void close() throws IOException {
-        super.close();
-        channel.disconnect();
-      }
-    };
-  }
+  public abstract Reader execAndReturnReader(String command) throws Exception;
 
   private boolean hasError() {
     return error != null;
@@ -120,46 +65,23 @@
     assertThat(getError()).contains(error);
   }
 
-  public void close() {
-    if (session != null) {
-      session.disconnect();
-      session = null;
-    }
-  }
-
-  private Session getSession() throws Exception {
-    if (session == null) {
-      KeyPair keyPair = sshKeys.getKeyPair(account);
-      JSch jsch = new JSch();
-      jsch.addIdentity(
-          "KeyPair", TestSshKeys.privateKey(keyPair), keyPair.getPublicKeyBlob(), null);
-      String username =
-          account
-              .username()
-              .orElseThrow(
-                  () ->
-                      new IllegalStateException(
-                          "account " + account.accountId() + " must have a username to use SSH"));
-      session = jsch.getSession(username, addr.getAddress().getHostAddress(), addr.getPort());
-      session.setConfig("StrictHostKeyChecking", "no");
-      session.connect();
-    }
-    return session;
-  }
-
   public String getUrl() {
-    checkState(session != null, "session must be opened");
     StringBuilder b = new StringBuilder();
     b.append("ssh://");
-    b.append(session.getUserName());
+    b.append(account.username().get());
     b.append("@");
-    b.append(session.getHost());
+    b.append(addr.getAddress().getHostAddress());
     b.append(":");
-    b.append(session.getPort());
+    b.append(addr.getPort());
     return b.toString();
   }
 
-  public TestAccount getAccount() {
-    return account;
+  protected String getUsername() {
+    return account
+        .username()
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    "account " + account.accountId() + " must have a username to use SSH"));
   }
 }
diff --git a/java/com/google/gerrit/acceptance/SshSessionJsch.java b/java/com/google/gerrit/acceptance/SshSessionJsch.java
new file mode 100644
index 0000000..a86c2d6
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/SshSessionJsch.java
@@ -0,0 +1,174 @@
+// 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.nio.charset.StandardCharsets.US_ASCII;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.acceptance.testsuite.account.TestAccount;
+import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import com.jcraft.jsch.ChannelExec;
+import com.jcraft.jsch.JSch;
+import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.Session;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.StringWriter;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Properties;
+import java.util.Scanner;
+import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
+import org.bouncycastle.openssl.jcajce.JcaPKCS8Generator;
+import org.bouncycastle.util.io.pem.PemObject;
+import org.eclipse.jgit.transport.JschConfigSessionFactory;
+import org.eclipse.jgit.transport.OpenSshConfig.Host;
+import org.eclipse.jgit.transport.SshSessionFactory;
+import org.eclipse.jgit.util.FS;
+
+public class SshSessionJsch extends SshSession {
+
+  private Session session;
+
+  public static void initClient(KeyPair keyPair) {
+    Properties config = new Properties();
+    config.put("StrictHostKeyChecking", "no");
+    JSch.setConfig(config);
+
+    // register a JschConfigSessionFactory that adds the private key as identity
+    // to the JSch instance of JGit so that SSH communication via JGit can
+    // succeed
+    SshSessionFactory.setInstance(
+        new JschConfigSessionFactory() {
+          @Override
+          protected void configure(Host hc, Session session) {
+            try {
+              JSch jsch = getJSch(hc, FS.DETECTED);
+              jsch.addIdentity(
+                  "KeyPair", privateKey(keyPair), TestSshKeys.publicKeyBlob(keyPair), null);
+            } catch (JSchException | GeneralSecurityException | IOException e) {
+              throw new RuntimeException(e);
+            }
+          }
+        });
+  }
+
+  public static KeyPairGenerator initKeyPairGenerator() throws NoSuchAlgorithmException {
+    KeyPairGenerator gen;
+    gen = KeyPairGenerator.getInstance("RSA");
+    gen.initialize(512, new SecureRandom());
+    return gen;
+  }
+
+  public SshSessionJsch(TestSshKeys sshKeys, InetSocketAddress addr, TestAccount account) {
+    super(sshKeys, addr, account);
+  }
+
+  @Override
+  public void open() throws Exception {
+    getJschSession();
+  }
+
+  @Override
+  public void close() {
+    if (session != null) {
+      session.disconnect();
+      session = null;
+    }
+  }
+
+  @SuppressWarnings("resource")
+  @Override
+  public String exec(String command) throws Exception {
+    ChannelExec channel = (ChannelExec) getJschSession().openChannel("exec");
+    try {
+      channel.setCommand(command);
+      InputStream in = channel.getInputStream();
+      InputStream err = channel.getErrStream();
+      channel.connect();
+
+      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");
+      return s.hasNext() ? s.next() : "";
+    } finally {
+      channel.disconnect();
+    }
+  }
+
+  @SuppressWarnings("resource")
+  @Override
+  public int execAndReturnStatus(String command) throws Exception {
+    ChannelExec channel = (ChannelExec) getJschSession().openChannel("exec");
+    try {
+      channel.setCommand(command);
+      InputStream err = channel.getErrStream();
+      channel.connect();
+
+      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
+      error = s.hasNext() ? s.next() : null;
+      return channel.getExitStatus();
+    } finally {
+      channel.disconnect();
+    }
+  }
+
+  @Override
+  public Reader execAndReturnReader(String command) throws Exception {
+    ChannelExec channel = (ChannelExec) getJschSession().openChannel("exec");
+    channel.setCommand(command);
+    channel.connect();
+
+    return new InputStreamReader(channel.getInputStream(), StandardCharsets.UTF_8) {
+      @Override
+      public void close() throws IOException {
+        super.close();
+        channel.disconnect();
+      }
+    };
+  }
+
+  private Session getJschSession() throws Exception {
+    if (session == null) {
+      KeyPair keyPair = sshKeys.getKeyPair(account);
+      JSch jsch = new JSch();
+      jsch.addIdentity("KeyPair", privateKey(keyPair), TestSshKeys.publicKeyBlob(keyPair), null);
+      String username = getUsername();
+      session = jsch.getSession(username, addr.getAddress().getHostAddress(), addr.getPort());
+      session.setConfig("StrictHostKeyChecking", "no");
+      session.connect();
+    }
+    return session;
+  }
+
+  private static byte[] privateKey(KeyPair keyPair) throws IOException {
+    // unencrypted form of PKCS#8 file
+    JcaPKCS8Generator gen1 = new JcaPKCS8Generator(keyPair.getPrivate(), null);
+    PemObject obj1 = gen1.generate();
+    StringWriter sw1 = new StringWriter();
+    try (JcaPEMWriter pw = new JcaPEMWriter(sw1)) {
+      pw.writeObject(obj1);
+    }
+    return sw1.toString().getBytes(US_ASCII.name());
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/SshSessionMina.java b/java/com/google/gerrit/acceptance/SshSessionMina.java
new file mode 100644
index 0000000..4d8691b
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/SshSessionMina.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.acceptance;
+
+import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.io.CharSink;
+import com.google.common.io.Files;
+import com.google.common.io.MoreFiles;
+import com.google.gerrit.acceptance.testsuite.account.TestAccount;
+import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.Reader;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.KeyPairGenerator;
+import java.security.spec.InvalidKeySpecException;
+import java.util.Arrays;
+import java.util.Scanner;
+import org.apache.sshd.common.cipher.ECCurves;
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter;
+import org.apache.sshd.common.util.security.SecurityUtils;
+import org.eclipse.jgit.transport.SshSessionFactory;
+import org.eclipse.jgit.transport.URIish;
+import org.eclipse.jgit.transport.sshd.DefaultProxyDataFactory;
+import org.eclipse.jgit.transport.sshd.JGitKeyCache;
+import org.eclipse.jgit.transport.sshd.SshdSession;
+import org.eclipse.jgit.transport.sshd.SshdSessionFactory;
+import org.eclipse.jgit.util.FS;
+
+public class SshSessionMina extends SshSession {
+  private static final int TIMEOUT = 100000;
+
+  private SshdSession session;
+
+  public static void initClient() {
+    JGitKeyCache keyCache = new JGitKeyCache();
+    SshdSessionFactory factory = new SshdSessionFactory(keyCache, new DefaultProxyDataFactory());
+    SshSessionFactory.setInstance(factory);
+  }
+
+  public static KeyPairGenerator initKeyPairGenerator()
+      throws GeneralSecurityException, InvalidKeySpecException, InvalidAlgorithmParameterException {
+    int size = 256;
+    KeyPairGenerator gen = SecurityUtils.getKeyPairGenerator(KeyUtils.EC_ALGORITHM);
+    ECCurves curve = ECCurves.fromCurveSize(size);
+    if (curve == null) {
+      throw new InvalidKeySpecException("Unknown curve for key size=" + size);
+    }
+    gen.initialize(curve.getParameters());
+    return gen;
+  }
+
+  public SshSessionMina(TestSshKeys sshKeys, InetSocketAddress addr, TestAccount account) {
+    super(sshKeys, addr, account);
+  }
+
+  @Override
+  public void open() throws Exception {
+    getMinaSession();
+  }
+
+  @Override
+  public void close() {
+    if (session != null) {
+      session.disconnect();
+      session = null;
+    }
+  }
+
+  @SuppressWarnings("resource")
+  @Override
+  public String exec(String command) throws Exception {
+    Process process = getMinaSession().exec(command, TIMEOUT);
+    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");
+    return s.hasNext() ? s.next() : "";
+  }
+
+  @SuppressWarnings("resource")
+  @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) {
+      // SSH command was interrupted
+      return -1;
+    }
+  }
+
+  @Override
+  public Reader execAndReturnReader(String command) throws Exception {
+    return new InputStreamReader(
+        getMinaSession().exec(command, 0).getInputStream(), StandardCharsets.UTF_8);
+  }
+
+  private SshdSession getMinaSession() throws Exception {
+    if (session == null) {
+      String username = getUsername();
+
+      URIish uri =
+          new URIish(
+              "ssh://"
+                  + username
+                  + "@"
+                  + addr.getAddress().getHostAddress()
+                  + ":"
+                  + addr.getPort());
+
+      // TODO(davido): Switch to memory only key resolving mode.
+      File userhome = Files.createTempDir();
+
+      FS fs = FS.DETECTED.setUserHome(userhome);
+      File sshDir = new File(userhome, ".ssh");
+      sshDir.mkdir();
+      OpenSSHKeyPairResourceWriter keyPairWriter = new OpenSSHKeyPairResourceWriter();
+      try (OutputStream out = new FileOutputStream(new File(sshDir, "id_ecdsa"))) {
+        keyPairWriter.writePrivateKey(sshKeys.getKeyPair(account), null, null, out);
+      }
+
+      // TODO(davido): Disable programmatically host key checking: "StrictHostKeyChecking: no" mode.
+      CharSink configFile = Files.asCharSink(new File(sshDir, "config"), UTF_8);
+      configFile.writeLines(Arrays.asList("Host *", "StrictHostKeyChecking no"));
+
+      JGitKeyCache keyCache = new JGitKeyCache();
+      try (SshdSessionFactory factory =
+          new SshdSessionFactory(keyCache, new DefaultProxyDataFactory())) {
+        factory.setHomeDirectory(userhome);
+        factory.setSshDirectory(sshDir);
+
+        session = factory.getSession(uri, null, fs, TIMEOUT);
+
+        session.addCloseListener(
+            future -> {
+              try {
+                MoreFiles.deleteRecursively(userhome.toPath(), ALLOW_INSECURE);
+              } catch (IOException e) {
+                e.printStackTrace();
+              }
+            });
+      }
+    }
+    return session;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/TestMetricMaker.java b/java/com/google/gerrit/acceptance/TestMetricMaker.java
new file mode 100644
index 0000000..d60ef1a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/TestMetricMaker.java
@@ -0,0 +1,78 @@
+// 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.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.inject.Singleton;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.commons.lang.mutable.MutableLong;
+
+/**
+ * {@link com.google.gerrit.metrics.MetricMaker} to be bound in tests.
+ *
+ * <p>Records how often {@link Counter0} metrics are invoked. Metrics of other types are not
+ * recorded.
+ *
+ * <p>Allows test to check how much a {@link Counter0} metrics is increased by an operation.
+ *
+ * <p>Example:
+ *
+ * <pre>
+ * public class MyTest extends AbstractDaemonTest {
+ *   @Inject private TestMetricMaker testMetricMaker;
+ *
+ *   ...
+ *
+ *   @Test
+ *   public void testFoo() throws Exception {
+ *     testMetricMaker.reset();
+ *     doSomething();
+ *     assertThat(testMetricMaker.getCount("foo/bar_count")).isEqualsTo(1);
+ *   }
+ * }
+ * </pre>
+ */
+@Singleton
+public class TestMetricMaker extends DisabledMetricMaker {
+  private final Map<String, MutableLong> counts = new HashMap<>();
+
+  public long getCount(String counter0Name) {
+    return get(counter0Name).longValue();
+  }
+
+  public void reset() {
+    counts.clear();
+  }
+
+  private MutableLong get(String counter0Name) {
+    return counts.computeIfAbsent(counter0Name, name -> new MutableLong(0));
+  }
+
+  @Override
+  public Counter0 newCounter(String name, Description desc) {
+    return new Counter0() {
+      @Override
+      public void incrementBy(long value) {
+        get(name).add(value);
+      }
+
+      @Override
+      public void remove() {}
+    };
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java b/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
index 6c95360..277d219 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
@@ -18,19 +18,20 @@
 import static java.nio.charset.StandardCharsets.US_ASCII;
 
 import com.google.gerrit.acceptance.SshEnabled;
+import com.google.gerrit.acceptance.testsuite.request.SshSessionFactory;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import com.jcraft.jsch.JSch;
-import com.jcraft.jsch.JSchException;
-import com.jcraft.jsch.KeyPair;
 import java.io.ByteArrayOutputStream;
-import java.io.UnsupportedEncodingException;
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
 import java.util.HashMap;
 import java.util.Map;
+import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter;
 
 @Singleton
 public class TestSshKeys {
@@ -86,27 +87,26 @@
 
   private KeyPair createKeyPair(Account.Id accountId, String username, @Nullable String email)
       throws Exception {
-    KeyPair keyPair = genSshKey();
+    KeyPair keyPair = SshSessionFactory.genSshKey();
     authorizedKeys.addKey(accountId, publicKey(keyPair, email));
     sshKeyCache.evict(username);
     return keyPair;
   }
 
-  public static KeyPair genSshKey() throws JSchException {
-    JSch jsch = new JSch();
-    return KeyPair.genKeyPair(jsch, KeyPair.ECDSA, 256);
-  }
-
   public static String publicKey(KeyPair sshKey, @Nullable String comment)
-      throws UnsupportedEncodingException {
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    sshKey.writePublicKey(out, comment);
-    return out.toString(US_ASCII.name()).trim();
+      throws IOException, GeneralSecurityException {
+    return preparePublicKey(sshKey, comment).toString(US_ASCII.name()).trim();
   }
 
-  public static byte[] privateKey(KeyPair keyPair) {
+  public static byte[] publicKeyBlob(KeyPair sshKey) throws IOException, GeneralSecurityException {
+    return preparePublicKey(sshKey, null).toByteArray();
+  }
+
+  private static ByteArrayOutputStream preparePublicKey(KeyPair sshKey, String comment)
+      throws IOException, GeneralSecurityException {
+    OpenSSHKeyPairResourceWriter keyPairWriter = new OpenSSHKeyPairResourceWriter();
     ByteArrayOutputStream out = new ByteArrayOutputStream();
-    keyPair.writePrivateKey(out);
-    return out.toByteArray();
+    keyPairWriter.writePublicKey(sshKey, comment, out);
+    return out;
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
index 21d1232..cde5134 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
@@ -17,12 +17,12 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 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.InternalGroup;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
diff --git a/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java
index db730a6..895c7a0 100644
--- a/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java
@@ -19,7 +19,6 @@
 
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.GerritServer.TestSshServerAddress;
-import com.google.gerrit.acceptance.SshSession;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
@@ -82,7 +81,7 @@
   public AcceptanceTestRequestScope.Context setApiUser(TestAccount testAccount) {
     return atrScope.set(
         atrScope.newContext(
-            new SshSession(testSshKeys, sshAddress, testAccount),
+            SshSessionFactory.createSession(testSshKeys, sshAddress, testAccount),
             createIdentifiedUser(testAccount.accountId())));
   }
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java b/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java
new file mode 100644
index 0000000..d5dd28a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.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.acceptance.testsuite.request;
+
+import static com.google.gerrit.server.config.SshClientImplementation.getFromEnvironment;
+
+import com.google.gerrit.acceptance.SshSession;
+import com.google.gerrit.acceptance.SshSessionJsch;
+import com.google.gerrit.acceptance.SshSessionMina;
+import com.google.gerrit.acceptance.testsuite.account.TestAccount;
+import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import java.net.InetSocketAddress;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+
+public class SshSessionFactory {
+  public static SshSession createSession(
+      TestSshKeys testSshKeys, InetSocketAddress sshAddress, TestAccount testAccount) {
+    return getFromEnvironment().isMina()
+        ? new SshSessionMina(testSshKeys, sshAddress, testAccount)
+        : new SshSessionJsch(testSshKeys, sshAddress, testAccount);
+  }
+
+  public static void initSsh(KeyPair keyPair) {
+    if (getFromEnvironment().isMina()) {
+      SshSessionMina.initClient();
+    } else {
+      SshSessionJsch.initClient(keyPair);
+    }
+  }
+
+  private SshSessionFactory() {}
+
+  public static KeyPair genSshKey() throws GeneralSecurityException {
+    return (getFromEnvironment().isMina()
+            ? SshSessionMina.initKeyPairGenerator()
+            : SshSessionJsch.initKeyPairGenerator())
+        .generateKeyPair();
+  }
+}
diff --git a/java/com/google/gerrit/server/config/AuthModule.java b/java/com/google/gerrit/auth/AuthModule.java
similarity index 83%
rename from java/com/google/gerrit/server/config/AuthModule.java
rename to java/com/google/gerrit/auth/AuthModule.java
index 56f2e2a..1eabe3f 100644
--- a/java/com/google/gerrit/server/config/AuthModule.java
+++ b/java/com/google/gerrit/auth/AuthModule.java
@@ -12,30 +12,32 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.auth;
 
+import com.google.gerrit.auth.ldap.LdapModule;
+import com.google.gerrit.auth.oauth.OAuthRealm;
+import com.google.gerrit.auth.oauth.OAuthTokenCache;
+import com.google.gerrit.auth.openid.OpenIdRealm;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.account.DefaultRealm;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.InternalAuthBackend;
-import com.google.gerrit.server.auth.ldap.LdapModule;
-import com.google.gerrit.server.auth.oauth.OAuthRealm;
-import com.google.gerrit.server.auth.openid.OpenIdRealm;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.AbstractModule;
-import com.google.inject.Inject;
 
 public class AuthModule extends AbstractModule {
   private final AuthType loginType;
 
-  @Inject
-  AuthModule(AuthConfig authConfig) {
+  public AuthModule(AuthConfig authConfig) {
     loginType = authConfig.getAuthType();
   }
 
   @Override
   protected void configure() {
+    install(OAuthTokenCache.module());
+
     switch (loginType) {
       case HTTP_LDAP:
       case LDAP:
diff --git a/java/com/google/gerrit/auth/BUILD b/java/com/google/gerrit/auth/BUILD
new file mode 100644
index 0000000..e844696
--- /dev/null
+++ b/java/com/google/gerrit/auth/BUILD
@@ -0,0 +1,40 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:javadoc.bzl", "java_doc")
+
+# Giant kitchen-sink target.
+#
+# The only reason this hasn't been split up further is because we have too many
+# tangled dependencies (and Guice unfortunately makes it quite easy to get into
+# this state). Which means if you see an opportunity to split something off, you
+# should seize it.
+java_library(
+    name = "auth",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/server"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/proto",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/logging",
+        "//java/com/google/gerrit/util/ssl",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib:protobuf",
+        "//lib:servlet-api",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
+        "//lib/flogger:api",
+        "//lib/guice",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java b/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
similarity index 90%
rename from java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java
rename to java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
index 63cd426..8fb4d35 100644
--- a/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java
+++ b/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
@@ -12,15 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
-import static com.google.gerrit.server.auth.ldap.Helper.LDAP_UUID;
+import static com.google.gerrit.auth.ldap.Helper.LDAP_UUID;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
@@ -76,7 +76,7 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
+  public GroupMembership membershipsOf(CurrentUser user) {
     return new ListGroupMembership(Collections.emptyList());
   }
 
diff --git a/java/com/google/gerrit/server/auth/ldap/Helper.java b/java/com/google/gerrit/auth/ldap/Helper.java
similarity index 99%
rename from java/com/google/gerrit/server/auth/ldap/Helper.java
rename to java/com/google/gerrit/auth/ldap/Helper.java
index b0f011a..a939c72 100644
--- a/java/com/google/gerrit/server/auth/ldap/Helper.java
+++ b/java/com/google/gerrit/auth/ldap/Helper.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import com.google.common.base.Throwables;
 import com.google.common.cache.Cache;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java b/java/com/google/gerrit/auth/ldap/LdapAuthBackend.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java
rename to java/com/google/gerrit/auth/ldap/LdapAuthBackend.java
index f31954e..017655f 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java
+++ b/java/com/google/gerrit/auth/ldap/LdapAuthBackend.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.client.AuthType;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
similarity index 90%
rename from java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
rename to java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
index cd1e0a4..2947efd 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
+++ b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
@@ -12,28 +12,27 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
+import static com.google.gerrit.auth.ldap.Helper.LDAP_UUID;
+import static com.google.gerrit.auth.ldap.LdapModule.GROUP_CACHE;
+import static com.google.gerrit.auth.ldap.LdapModule.GROUP_EXIST_CACHE;
 import static com.google.gerrit.server.account.GroupBackends.GROUP_REF_NAME_COMPARATOR;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
-import static com.google.gerrit.server.auth.ldap.Helper.LDAP_UUID;
-import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_CACHE;
-import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_EXIST_CACHE;
 
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.auth.ldap.Helper.LdapSchema;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.auth.ldap.Helper.LdapSchema;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -45,6 +44,7 @@
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import javax.naming.InvalidNameException;
@@ -178,21 +178,13 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
-    String id = findId(user.state().externalIds());
-    if (id == null) {
+  public GroupMembership membershipsOf(CurrentUser user) {
+    Optional<ExternalId.Key> id =
+        user.getExternalIdKeys().stream().filter(e -> e.isScheme(SCHEME_GERRIT)).findAny();
+    if (!id.isPresent()) {
       return GroupMembership.EMPTY;
     }
-    return new LdapGroupMembership(membershipCache, projectCache, id, gerritConfig);
-  }
-
-  private static String findId(Collection<ExternalId> extIds) {
-    for (ExternalId extId : extIds) {
-      if (extId.isScheme(SCHEME_GERRIT)) {
-        return extId.key().id();
-      }
-    }
-    return null;
+    return new LdapGroupMembership(membershipCache, projectCache, id.get().id(), gerritConfig);
   }
 
   private Set<GroupReference> suggestLdap(String name) {
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java b/java/com/google/gerrit/auth/ldap/LdapGroupMembership.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
rename to java/com/google/gerrit/auth/ldap/LdapGroupMembership.java
index a6aa2f6..0b8b6e2 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
+++ b/java/com/google/gerrit/auth/ldap/LdapGroupMembership.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import com.google.common.cache.LoadingCache;
 import com.google.gerrit.entities.AccountGroup;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapModule.java b/java/com/google/gerrit/auth/ldap/LdapModule.java
similarity index 97%
rename from java/com/google/gerrit/server/auth/ldap/LdapModule.java
rename to java/com/google/gerrit/auth/ldap/LdapModule.java
index 092b5ac..a5ee904 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapModule.java
+++ b/java/com/google/gerrit/auth/ldap/LdapModule.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapQuery.java b/java/com/google/gerrit/auth/ldap/LdapQuery.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/ldap/LdapQuery.java
rename to java/com/google/gerrit/auth/ldap/LdapQuery.java
index 3e549f6..409c9f5 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapQuery.java
+++ b/java/com/google/gerrit/auth/ldap/LdapQuery.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.metrics.Timer0;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/java/com/google/gerrit/auth/ldap/LdapRealm.java
similarity index 99%
rename from java/com/google/gerrit/server/auth/ldap/LdapRealm.java
rename to java/com/google/gerrit/auth/ldap/LdapRealm.java
index b5972e2..9305914 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/auth/ldap/LdapRealm.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapType.java b/java/com/google/gerrit/auth/ldap/LdapType.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/ldap/LdapType.java
rename to java/com/google/gerrit/auth/ldap/LdapType.java
index fe1f1ff..c486335 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapType.java
+++ b/java/com/google/gerrit/auth/ldap/LdapType.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import javax.naming.NamingException;
 import javax.naming.directory.Attribute;
diff --git a/java/com/google/gerrit/server/auth/ldap/SearchScope.java b/java/com/google/gerrit/auth/ldap/SearchScope.java
similarity index 96%
rename from java/com/google/gerrit/server/auth/ldap/SearchScope.java
rename to java/com/google/gerrit/auth/ldap/SearchScope.java
index 0038608..75edd8d 100644
--- a/java/com/google/gerrit/server/auth/ldap/SearchScope.java
+++ b/java/com/google/gerrit/auth/ldap/SearchScope.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import javax.naming.directory.SearchControls;
 
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java b/java/com/google/gerrit/auth/oauth/OAuthRealm.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
rename to java/com/google/gerrit/auth/oauth/OAuthRealm.java
index 944bd44..c329cc0 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
+++ b/java/com/google/gerrit/auth/oauth/OAuthRealm.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.oauth;
+package com.google.gerrit.auth.oauth;
 
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
 
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
rename to java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
index 03ecd91..b0c1f51 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.oauth;
+package com.google.gerrit.auth.oauth;
 
 import static java.util.Objects.requireNonNull;
 
diff --git a/java/com/google/gerrit/server/auth/openid/OpenIdRealm.java b/java/com/google/gerrit/auth/openid/OpenIdRealm.java
similarity index 97%
rename from java/com/google/gerrit/server/auth/openid/OpenIdRealm.java
rename to java/com/google/gerrit/auth/openid/OpenIdRealm.java
index 4e1a1ce..2a65e76 100644
--- a/java/com/google/gerrit/server/auth/openid/OpenIdRealm.java
+++ b/java/com/google/gerrit/auth/openid/OpenIdRealm.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.openid;
+package com.google.gerrit.auth.openid;
 
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_HTTP;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_HTTPS;
diff --git a/java/com/google/gerrit/common/data/PatchScript.java b/java/com/google/gerrit/common/data/PatchScript.java
index e3c0ba6..1ba5592 100644
--- a/java/com/google/gerrit/common/data/PatchScript.java
+++ b/java/com/google/gerrit/common/data/PatchScript.java
@@ -34,7 +34,17 @@
   public enum FileMode {
     FILE,
     SYMLINK,
-    GITLINK
+    GITLINK;
+
+    public static FileMode fromJgitFileMode(org.eclipse.jgit.lib.FileMode jgitFileMode) {
+      PatchScript.FileMode fileMode = PatchScript.FileMode.FILE;
+      if (jgitFileMode == org.eclipse.jgit.lib.FileMode.SYMLINK) {
+        fileMode = FileMode.SYMLINK;
+      } else if (jgitFileMode == org.eclipse.jgit.lib.FileMode.GITLINK) {
+        fileMode = FileMode.GITLINK;
+      }
+      return fileMode;
+    }
   }
 
   public static class PatchScriptFileInfo {
diff --git a/java/com/google/gerrit/common/data/testing/BUILD b/java/com/google/gerrit/common/data/testing/BUILD
index b9ec30b..d39d05c 100644
--- a/java/com/google/gerrit/common/data/testing/BUILD
+++ b/java/com/google/gerrit/common/data/testing/BUILD
@@ -6,7 +6,6 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//lib/truth",
     ],
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index d53490b..6ed1a51 100644
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -84,6 +84,9 @@
   protected static final String BULK = "_bulk";
   protected static final String MAPPINGS = "mappings";
   protected static final String ORDER = "order";
+  protected static final String DESC_SORT_ORDER = "desc";
+  protected static final String ASC_SORT_ORDER = "asc";
+  protected static final String UNMAPPED_TYPE = "unmapped_type";
   protected static final String SEARCH = "_search";
   protected static final String SETTINGS = "settings";
 
@@ -294,7 +297,7 @@
 
   protected JsonArray getSortArray(String idFieldName) {
     JsonObject properties = new JsonObject();
-    properties.addProperty(ORDER, "asc");
+    properties.addProperty(ORDER, ASC_SORT_ORDER);
 
     JsonArray sortArray = new JsonArray();
     addNamedElement(idFieldName, properties, sortArray);
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 625a598..162654d 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
@@ -57,6 +58,9 @@
 import com.google.gson.JsonObject;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
 import java.util.Collections;
 import java.util.Optional;
 import java.util.Set;
@@ -133,14 +137,24 @@
 
   private JsonArray getSortArray() {
     JsonObject properties = new JsonObject();
-    properties.addProperty(ORDER, "desc");
+    properties.addProperty(ORDER, DESC_SORT_ORDER);
 
     JsonArray sortArray = new JsonArray();
     addNamedElement(ChangeField.UPDATED.getName(), properties, sortArray);
+    addNamedElement(ChangeField.MERGED_ON.getName(), getMergedOnSortOptions(), sortArray);
     addNamedElement(idField.getName(), properties, sortArray);
     return sortArray;
   }
 
+  private JsonObject getMergedOnSortOptions() {
+    JsonObject sortOptions = new JsonObject();
+    sortOptions.addProperty(ORDER, DESC_SORT_ORDER);
+    // Ignore the sort field if it does not exist in index. Otherwise the search would fail on open
+    // changes, because the corresponding documents do not have mergedOn field.
+    sortOptions.addProperty(UNMAPPED_TYPE, ElasticMapping.TIMESTAMP_FIELD_TYPE);
+    return sortOptions;
+  }
+
   @Override
   protected String getDeleteActions(Change.Id c) {
     return getDeleteRequest(c);
@@ -341,7 +355,7 @@
 
     // Ref-state.
     if (fields.contains(ChangeField.REF_STATE.getName())) {
-      cd.setRefStates(getByteArray(source, ChangeField.REF_STATE.getName()));
+      cd.setRefStates(RefState.parseStates(getByteArray(source, ChangeField.REF_STATE.getName())));
     }
 
     // Ref-state-pattern.
@@ -361,6 +375,10 @@
           cd);
     }
 
+    if (fields.contains(ChangeField.MERGED_ON.getName())) {
+      decodeMergedOn(source, cd);
+    }
+
     return cd;
   }
 
@@ -396,4 +414,18 @@
     }
     out.setUnresolvedCommentCount(count.getAsInt());
   }
+
+  private void decodeMergedOn(JsonObject doc, ChangeData out) {
+    JsonElement mergedOnField = doc.get(ChangeField.MERGED_ON.getName());
+
+    Timestamp mergedOn = null;
+    if (mergedOnField != null) {
+      // Parse from ElasticMapping.TIMESTAMP_FIELD_FORMAT.
+      // We currently use built-in ISO-based dateOptionalTime.
+      // https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-date-format.html#built-in-date-formats
+      DateTimeFormatter isoFormatter = DateTimeFormatter.ISO_INSTANT;
+      mergedOn = Timestamp.from(Instant.from(isoFormatter.parse(mergedOnField.getAsString())));
+    }
+    out.setMergedOn(mergedOn);
+  }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
index 06b128c..c4435297 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
@@ -26,8 +26,10 @@
 import java.net.URISyntaxException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
 import org.apache.http.HttpHost;
 import org.eclipse.jgit.lib.Config;
+import org.elasticsearch.client.RestClientBuilder;
 
 @Singleton
 class ElasticConfiguration {
@@ -41,12 +43,16 @@
   static final String KEY_NUMBER_OF_SHARDS = "numberOfShards";
   static final String KEY_NUMBER_OF_REPLICAS = "numberOfReplicas";
   static final String KEY_MAX_RESULT_WINDOW = "maxResultWindow";
+  static final String KEY_CONNECT_TIMEOUT = "connectTimeout";
+  static final String KEY_SOCKET_TIMEOUT = "socketTimeout";
 
   static final String DEFAULT_PORT = "9200";
   static final String DEFAULT_USERNAME = "elastic";
   static final int DEFAULT_NUMBER_OF_SHARDS = 1;
   static final int DEFAULT_NUMBER_OF_REPLICAS = 1;
   static final int DEFAULT_MAX_RESULT_WINDOW = 10000;
+  static final int DEFAULT_CONNECT_TIMEOUT = RestClientBuilder.DEFAULT_CONNECT_TIMEOUT_MILLIS;
+  static final int DEFAULT_SOCKET_TIMEOUT = RestClientBuilder.DEFAULT_SOCKET_TIMEOUT_MILLIS;
 
   private final Config cfg;
   private final List<HttpHost> hosts;
@@ -56,6 +62,8 @@
   final int numberOfShards;
   final int numberOfReplicas;
   final int maxResultWindow;
+  final int connectTimeout;
+  final int socketTimeout;
   final String prefix;
 
   @Inject
@@ -74,6 +82,22 @@
         cfg.getInt(SECTION_ELASTICSEARCH, null, KEY_NUMBER_OF_REPLICAS, DEFAULT_NUMBER_OF_REPLICAS);
     this.maxResultWindow =
         cfg.getInt(SECTION_ELASTICSEARCH, null, KEY_MAX_RESULT_WINDOW, DEFAULT_MAX_RESULT_WINDOW);
+    this.connectTimeout =
+        (int)
+            cfg.getTimeUnit(
+                SECTION_ELASTICSEARCH,
+                null,
+                KEY_CONNECT_TIMEOUT,
+                DEFAULT_CONNECT_TIMEOUT,
+                TimeUnit.MILLISECONDS);
+    this.socketTimeout =
+        (int)
+            cfg.getTimeUnit(
+                SECTION_ELASTICSEARCH,
+                null,
+                KEY_SOCKET_TIMEOUT,
+                DEFAULT_SOCKET_TIMEOUT,
+                TimeUnit.MILLISECONDS);
     this.hosts = new ArrayList<>();
     for (String server : cfg.getStringList(SECTION_ELASTICSEARCH, null, KEY_SERVER)) {
       try {
diff --git a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
index f8c2ec5..781ed43 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -20,6 +20,7 @@
 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;
@@ -28,7 +29,6 @@
 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.group.InternalGroup;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.group.GroupField;
 import com.google.gerrit.server.index.group.GroupIndex;
diff --git a/java/com/google/gerrit/elasticsearch/ElasticMapping.java b/java/com/google/gerrit/elasticsearch/ElasticMapping.java
index f8c4168..edd05c9 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticMapping.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticMapping.java
@@ -21,6 +21,10 @@
 import java.util.Map;
 
 class ElasticMapping {
+
+  protected static final String TIMESTAMP_FIELD_TYPE = "date";
+  protected static final String TIMESTAMP_FIELD_FORMAT = "dateOptionalTime";
+
   static MappingProperties createMapping(Schema<?> schema, ElasticQueryAdapter adapter) {
     ElasticMapping.Builder mapping = new ElasticMapping.Builder(adapter);
     for (FieldDef<?, ?> field : schema.getFields().values()) {
@@ -71,9 +75,9 @@
     }
 
     Builder addTimestamp(String name) {
-      FieldProperties properties = new FieldProperties("date");
-      properties.type = "date";
-      properties.format = "dateOptionalTime";
+      FieldProperties properties = new FieldProperties(TIMESTAMP_FIELD_TYPE);
+      properties.type = TIMESTAMP_FIELD_TYPE;
+      properties.format = TIMESTAMP_FIELD_FORMAT;
       fields.put(name, properties);
       return this;
     }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java b/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
index d05e91c..40ac603 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.RegexPredicate;
 import com.google.gerrit.index.query.TimestampRangePredicate;
-import com.google.gerrit.server.query.change.AfterPredicate;
 import java.time.Instant;
 
 public class ElasticQueryBuilder {
@@ -130,7 +129,9 @@
   private <T> QueryBuilder timestampQuery(IndexPredicate<T> p) throws QueryParseException {
     if (p instanceof TimestampRangePredicate) {
       TimestampRangePredicate<T> r = (TimestampRangePredicate<T>) p;
-      if (p instanceof AfterPredicate) {
+      if (r.getMaxTimestamp().getTime() == Long.MAX_VALUE) {
+        // The time range only has the start value, search from the start to the max supported value
+        // Long.MAX_VALUE
         return QueryBuilders.rangeQuery(r.getField().getName())
             .gte(Instant.ofEpochMilli(r.getMinTimestamp().getTime()));
       }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
index f635b23..b41f365 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
@@ -27,6 +27,7 @@
 import org.apache.http.auth.AuthScope;
 import org.apache.http.auth.UsernamePasswordCredentials;
 import org.apache.http.client.CredentialsProvider;
+import org.apache.http.client.config.RequestConfig;
 import org.apache.http.impl.client.BasicCredentialsProvider;
 import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
 import org.elasticsearch.client.Request;
@@ -128,10 +129,19 @@
 
   private RestClient build() {
     RestClientBuilder builder = RestClient.builder(cfg.getHosts());
+    setConfiguredTimeouts(builder);
     setConfiguredCredentialsIfAny(builder);
     return builder.build();
   }
 
+  private void setConfiguredTimeouts(RestClientBuilder builder) {
+    builder.setRequestConfigCallback(
+        (RequestConfig.Builder requestConfigBuilder) ->
+            requestConfigBuilder
+                .setConnectTimeout(cfg.connectTimeout)
+                .setSocketTimeout(cfg.socketTimeout));
+  }
+
   private void setConfiguredCredentialsIfAny(RestClientBuilder builder) {
     String username = cfg.username;
     String password = cfg.password;
diff --git a/java/com/google/gerrit/entities/BUILD b/java/com/google/gerrit/entities/BUILD
index 66d1869..c0f5de6 100644
--- a/java/com/google/gerrit/entities/BUILD
+++ b/java/com/google/gerrit/entities/BUILD
@@ -10,11 +10,13 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
+        "//lib:gson",
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/auto:auto-value-gson",
         "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//proto:cache_java_proto",
diff --git a/java/com/google/gerrit/entities/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
index 0b755b7..2a94bc8 100644
--- a/java/com/google/gerrit/entities/CachedProjectConfig.java
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -14,19 +14,17 @@
 
 package com.google.gerrit.entities;
 
-import static com.google.common.base.Preconditions.checkState;
-
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
@@ -37,6 +35,8 @@
  */
 @AutoValue
 public abstract class CachedProjectConfig {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public abstract Project getProject();
 
   public abstract ImmutableMap<AccountGroup.UUID, GroupReference> getGroups();
@@ -126,34 +126,10 @@
 
   public abstract ImmutableMap<String, String> getPluginConfigs();
 
-  /**
-   * Returns the {@link Config} that got parsed from the specified {@code fileName} on {@code
-   * refs/meta/config}. The returned instance is a defensive copy of the cached value.
-   *
-   * @param fileName the name of the file. Must end in {@code .config}.
-   * @return an {@link Optional} of the {@link Config}. {@link Optional#empty()} if the file was not
-   *     found or could not be parsed. {@link com.google.gerrit.server.project.ProjectConfig} will
-   *     surface validation errors in case of a parsing issue.
-   */
-  public Optional<Config> getProjectLevelConfig(String fileName) {
-    checkState(fileName.endsWith(".config"), "file name must end in .config");
-    if (getProjectLevelConfigs().containsKey(fileName)) {
-      Config config = new Config();
-      try {
-        config.fromText(getProjectLevelConfigs().get(fileName));
-      } catch (ConfigInvalidException e) {
-        // This is OK to propagate as IllegalStateException because it's a programmer error.
-        // The config was converted to a String using Config#toText. So #fromText must not
-        // throw a ConfigInvalidException
-        throw new IllegalStateException("invalid config for " + fileName, e);
-      }
-      return Optional.of(config);
-    }
-    return Optional.empty();
-  }
-
   public abstract ImmutableMap<String, String> getProjectLevelConfigs();
 
+  public abstract ImmutableMap<String, ImmutableConfig> getParsedProjectLevelConfigs();
+
   public static Builder builder() {
     return new AutoValue_CachedProjectConfig.Builder();
   }
@@ -235,8 +211,15 @@
 
     abstract ImmutableMap.Builder<String, String> projectLevelConfigsBuilder();
 
+    abstract ImmutableMap.Builder<String, ImmutableConfig> parsedProjectLevelConfigsBuilder();
+
     public Builder addProjectLevelConfig(String configFileName, String config) {
       projectLevelConfigsBuilder().put(configFileName, config);
+      try {
+        parsedProjectLevelConfigsBuilder().put(configFileName, ImmutableConfig.parse(config));
+      } catch (ConfigInvalidException e) {
+        logger.atInfo().withCause(e).log("Config for " + configFileName + " not parsable");
+      }
       return this;
     }
 
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 845a9bb..ca13db9 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -21,6 +21,9 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import com.google.gson.annotations.SerializedName;
 import java.sql.Timestamp;
 import java.util.Arrays;
 import java.util.Optional;
@@ -100,6 +103,7 @@
     return new AutoValue_Change_Id(id);
   }
 
+  /** The numeric change ID */
   @AutoValue
   public abstract static class Id {
     /**
@@ -283,6 +287,7 @@
       return Change.key(KeyUtil.decode(str));
     }
 
+    @SerializedName("id")
     abstract String key();
 
     public String get() {
@@ -307,6 +312,10 @@
     public final String toString() {
       return get();
     }
+
+    public static TypeAdapter<Key> typeAdapter(Gson gson) {
+      return new AutoValue_Change_Key.GsonTypeAdapter(gson);
+    }
   }
 
   /** Minimum database status constant for an open change. */
@@ -447,20 +456,14 @@
    */
   protected Timestamp lastUpdatedOn;
 
-  // DELETED: id = 6 (sortkey)
-
   protected Account.Id owner;
 
   /** The branch (and project) this change merges into. */
   protected BranchNameKey dest;
 
-  // DELETED: id = 9 (open)
-
   /** Current state code; see {@link Status}. */
   protected char status;
 
-  // DELETED: id = 11 (nbrPatchSets)
-
   /** The current patch set. */
   protected int currentPatchSetId;
 
@@ -470,9 +473,6 @@
   /** Topic name assigned by the user, if any. */
   @Nullable protected String topic;
 
-  // DELETED: id = 15 (lastSha1MergeTested)
-  // DELETED: id = 16 (mergeable)
-
   /**
    * First line of first patch set's commit message.
    *
@@ -544,12 +544,12 @@
     cherryPickOf = other.cherryPickOf;
   }
 
-  /** Legacy 32 bit integer identity for a change. */
+  /** 32 bit integer identity for a change. */
   public Change.Id getId() {
     return changeId;
   }
 
-  /** Legacy 32 bit integer identity for a change. */
+  /** 32 bit integer identity for a change. */
   public int getChangeId() {
     return changeId.get();
   }
diff --git a/java/com/google/gerrit/entities/CommentContext.java b/java/com/google/gerrit/entities/CommentContext.java
new file mode 100644
index 0000000..68d779c
--- /dev/null
+++ b/java/com/google/gerrit/entities/CommentContext.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+
+/** An entity class representing all context lines of a comment. */
+@AutoValue
+public abstract class CommentContext {
+  private static final CommentContext EMPTY = new AutoValue_CommentContext(ImmutableMap.of(), "");
+
+  public static CommentContext create(ImmutableMap<Integer, String> lines, String contentType) {
+    return new AutoValue_CommentContext(lines, contentType);
+  }
+
+  /** Map of {line number, line text} of the context lines of a comment */
+  public abstract ImmutableMap<Integer, String> lines();
+
+  /**
+   * Content type of the source file. Useful for syntax highlighting.
+   *
+   * @return text/x-gerrit-commit-message if the file is a commit message.
+   *     <p>text/x-gerrit-merge-list if the file is a merge list.
+   *     <p>The content/mime type, e.g. text/x-c++src otherwise.
+   */
+  public abstract String contentType();
+
+  public static CommentContext empty() {
+    return EMPTY;
+  }
+}
diff --git a/java/com/google/gerrit/entities/CoreDownloadSchemes.java b/java/com/google/gerrit/entities/CoreDownloadSchemes.java
index 37c10f1..9bcd365 100644
--- a/java/com/google/gerrit/entities/CoreDownloadSchemes.java
+++ b/java/com/google/gerrit/entities/CoreDownloadSchemes.java
@@ -21,6 +21,7 @@
   public static final String HTTP = "http";
   public static final String SSH = "ssh";
   public static final String REPO_DOWNLOAD = "repo";
+  public static final String REPO = "repo";
 
   private CoreDownloadSchemes() {}
 }
diff --git a/java/com/google/gerrit/entities/EntitiesAdapterFactory.java b/java/com/google/gerrit/entities/EntitiesAdapterFactory.java
new file mode 100644
index 0000000..e6a06fd
--- /dev/null
+++ b/java/com/google/gerrit/entities/EntitiesAdapterFactory.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.gson.TypeAdapterFactory;
+import com.ryanharter.auto.value.gson.GsonTypeAdapterFactory;
+
+@GsonTypeAdapterFactory
+public abstract class EntitiesAdapterFactory implements TypeAdapterFactory {
+  public static TypeAdapterFactory create() {
+    return new AutoValueGson_EntitiesAdapterFactory();
+  }
+}
diff --git a/java/com/google/gerrit/entities/ImmutableConfig.java b/java/com/google/gerrit/entities/ImmutableConfig.java
new file mode 100644
index 0000000..a5efc14
--- /dev/null
+++ b/java/com/google/gerrit/entities/ImmutableConfig.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Immutable parsed representation of a {@link org.eclipse.jgit.lib.Config} that can be cached.
+ * Supports only a limited set of operations.
+ */
+public class ImmutableConfig {
+  public static final ImmutableConfig EMPTY = new ImmutableConfig("", new Config());
+
+  private final String stringCfg;
+  private final Config cfg;
+
+  private ImmutableConfig(String stringCfg, Config cfg) {
+    this.stringCfg = stringCfg;
+    this.cfg = cfg;
+  }
+
+  public static ImmutableConfig parse(String stringCfg) throws ConfigInvalidException {
+    Config cfg = new Config();
+    cfg.fromText(stringCfg);
+    return new ImmutableConfig(stringCfg, cfg);
+  }
+
+  /** Returns a mutable copy of this config. */
+  public Config mutableCopy() {
+    Config cfg = new Config();
+    try {
+      cfg.fromText(this.cfg.toText());
+    } catch (ConfigInvalidException e) {
+      // Can't happen as we used JGit to format that config.
+      throw new IllegalStateException(e);
+    }
+    return cfg;
+  }
+
+  /** @see Config#getSections() */
+  public Set<String> getSections() {
+    return cfg.getSections();
+  }
+
+  /** @see Config#getNames(String) */
+  public Set<String> getNames(String section) {
+    return cfg.getNames(section);
+  }
+
+  /** @see Config#getNames(String, String) */
+  public Set<String> getNames(String section, String subsection) {
+    return cfg.getNames(section, subsection);
+  }
+
+  /** @see Config#getStringList(String, String, String) */
+  public String[] getStringList(String section, String subsection, String name) {
+    return cfg.getStringList(section, subsection, name);
+  }
+
+  /** @see Config#getSubsections(String) */
+  public Set<String> getSubsections(String section) {
+    return cfg.getSubsections(section);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof ImmutableConfig)) {
+      return false;
+    }
+    return ((ImmutableConfig) o).stringCfg.equals(stringCfg);
+  }
+
+  @Override
+  public int hashCode() {
+    return stringCfg.hashCode();
+  }
+}
diff --git a/java/com/google/gerrit/server/group/InternalGroup.java b/java/com/google/gerrit/entities/InternalGroup.java
similarity index 94%
rename from java/com/google/gerrit/server/group/InternalGroup.java
rename to java/com/google/gerrit/entities/InternalGroup.java
index f33adaf..ebfa36a 100644
--- a/java/com/google/gerrit/server/group/InternalGroup.java
+++ b/java/com/google/gerrit/entities/InternalGroup.java
@@ -12,13 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.group;
+package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.AccountGroup;
 import java.io.Serializable;
 import java.sql.Timestamp;
 import org.eclipse.jgit.lib.ObjectId;
diff --git a/java/com/google/gerrit/entities/LabelId.java b/java/com/google/gerrit/entities/LabelId.java
index 1cc45c8..2426818 100644
--- a/java/com/google/gerrit/entities/LabelId.java
+++ b/java/com/google/gerrit/entities/LabelId.java
@@ -18,7 +18,9 @@
 
 @AutoValue
 public abstract class LabelId {
-  static final String LEGACY_SUBMIT_NAME = "SUBM";
+  public static final String LEGACY_SUBMIT_NAME = "SUBM";
+  public static final String CODE_REVIEW = "Code-Review";
+  public static final String VERIFIED = "Verified";
 
   public static LabelId create(String n) {
     return new AutoValue_LabelId(n);
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
index a8d4da5..9649642 100644
--- a/java/com/google/gerrit/entities/LabelType.java
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -29,6 +29,7 @@
 public abstract class LabelType {
   public static final boolean DEF_ALLOW_POST_SUBMIT = true;
   public static final boolean DEF_CAN_OVERRIDE = true;
+  public static final boolean DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE = false;
   public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
   public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
   public static final boolean DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = false;
@@ -101,6 +102,8 @@
 
   public abstract boolean isCopyMaxScore();
 
+  public abstract boolean isCopyAllScoresIfListOfFilesDidNotChange();
+
   public abstract boolean isCopyAllScoresOnMergeFirstParentUpdate();
 
   public abstract boolean isCopyAllScoresOnTrivialRebase();
@@ -143,6 +146,8 @@
         .setMaxNegative(Short.MIN_VALUE)
         .setMaxPositive(Short.MAX_VALUE)
         .setCanOverride(DEF_CAN_OVERRIDE)
+        .setCopyAllScoresIfListOfFilesDidNotChange(
+            DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE)
         .setCopyAllScoresIfNoChange(DEF_COPY_ALL_SCORES_IF_NO_CHANGE)
         .setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
         .setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
@@ -238,6 +243,9 @@
 
     public abstract Builder setCopyMaxScore(boolean copyMaxScore);
 
+    public abstract Builder setCopyAllScoresIfListOfFilesDidNotChange(
+        boolean copyAllScoresIfListOfFilesDidNotChange);
+
     public abstract Builder setCopyAllScoresOnMergeFirstParentUpdate(
         boolean copyAllScoresOnMergeFirstParentUpdate);
 
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/LegacySubmitRequirement.java
similarity index 74%
rename from java/com/google/gerrit/entities/SubmitRequirement.java
rename to java/com/google/gerrit/entities/LegacySubmitRequirement.java
index f9301a4..6e3c86e 100644
--- a/java/com/google/gerrit/entities/SubmitRequirement.java
+++ b/java/com/google/gerrit/entities/LegacySubmitRequirement.java
@@ -19,10 +19,10 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.CharMatcher;
 
-/** Describes a requirement to submit a change. */
+/** Describes a submit record requirement. Contains the requirement name and a description. */
 @AutoValue
 @AutoValue.CopyAnnotations
-public abstract class SubmitRequirement {
+public abstract class LegacySubmitRequirement {
   private static final CharMatcher TYPE_MATCHER =
       CharMatcher.inRange('a', 'z')
           .or(CharMatcher.inRange('A', 'Z'))
@@ -35,23 +35,25 @@
 
     public abstract Builder setFallbackText(String value);
 
-    public SubmitRequirement build() {
-      SubmitRequirement requirement = autoBuild();
+    public LegacySubmitRequirement build() {
+      LegacySubmitRequirement requirement = autoBuild();
       checkState(
           validateType(requirement.type()),
-          "SubmitRequirement's type contains non alphanumerical symbols.");
+          "LegacySubmitRequirement's type contains non alphanumerical symbols.");
       return requirement;
     }
 
-    abstract SubmitRequirement autoBuild();
+    abstract LegacySubmitRequirement autoBuild();
   }
 
+  /** Requirement description and explanation of what it does */
   public abstract String fallbackText();
 
+  /** Requirement name */
   public abstract String type();
 
   public static Builder builder() {
-    return new AutoValue_SubmitRequirement.Builder();
+    return new AutoValue_LegacySubmitRequirement.Builder();
   }
 
   private static boolean validateType(String type) {
diff --git a/java/com/google/gerrit/entities/Patch.java b/java/com/google/gerrit/entities/Patch.java
index e6b2167..856765b 100644
--- a/java/com/google/gerrit/entities/Patch.java
+++ b/java/com/google/gerrit/entities/Patch.java
@@ -160,5 +160,40 @@
     }
   }
 
+  /**
+   * Constants describing various file modes recognized by GIT. This is the Gerrit entity for {@link
+   * org.eclipse.jgit.lib.FileMode}.
+   */
+  public enum FileMode implements CodedEnum {
+    /** Mode indicating an entry is a tree (aka directory). */
+    TREE('T'),
+
+    /** Mode indicating an entry is a symbolic link. */
+    SYMLINK('S'),
+
+    /** Mode indicating an entry is a non-executable file. */
+    REGULAR_FILE('R'),
+
+    /** Mode indicating an entry is an executable file. */
+    EXECUTABLE_FILE('E'),
+
+    /** Mode indicating an entry is a submodule commit in another repository. */
+    GITLINK('G'),
+
+    /** Mode indicating an entry is missing during parallel walks. */
+    MISSING('M');
+
+    private final char code;
+
+    FileMode(char c) {
+      code = c;
+    }
+
+    @Override
+    public char getCode() {
+      return code;
+    }
+  }
+
   private Patch() {}
 }
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 3f04fa5..322c79e 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -140,6 +140,7 @@
     return true;
   }
 
+  /** The permission name, eg. {@code Permission.SUBMIT} */
   public abstract String getName();
 
   protected abstract boolean isExclusiveGroup();
diff --git a/java/com/google/gerrit/entities/RefNames.java b/java/com/google/gerrit/entities/RefNames.java
index 5595bc7..2263aba 100644
--- a/java/com/google/gerrit/entities/RefNames.java
+++ b/java/com/google/gerrit/entities/RefNames.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.entities;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.UsedAt;
 
 /** Constants and utilities for Gerrit-specific ref names. */
@@ -105,6 +106,26 @@
   /** A change starred by a user */
   public static final String REFS_STARRED_CHANGES = "refs/starred-changes/";
 
+  /**
+   * List of refs managed by Gerrit. Covers all Gerrit internal refs.
+   *
+   * <p><b>Caution</b> Any ref not in this list will be served if the user was granted a READ
+   * permission on it using Gerrit's permission model.
+   */
+  public static final ImmutableList<String> GERRIT_REFS =
+      ImmutableList.of(
+          REFS_CHANGES,
+          REFS_EXTERNAL_IDS,
+          REFS_CACHE_AUTOMERGE,
+          REFS_DRAFT_COMMENTS,
+          REFS_DELETED_GROUPS,
+          REFS_SEQUENCES,
+          REFS_GROUPS,
+          REFS_GROUPNAMES,
+          REFS_USERS,
+          REFS_STARRED_CHANGES,
+          REFS_REJECT_COMMITS);
+
   public static String fullName(String ref) {
     return (ref.startsWith(REFS) || ref.equals(HEAD)) ? ref : REFS_HEADS + ref;
   }
@@ -118,6 +139,11 @@
     return ref;
   }
 
+  /**
+   * Warning: Change refs have to manually be advertised in {@code
+   * com.google.gerrit.server.permissions.DefaultRefFilter}; this should be done when adding new
+   * change refs.
+   */
   public static String changeMetaRef(Change.Id id) {
     StringBuilder r = newStringBuilder().append(REFS_CHANGES);
     return shard(id.get(), r).append(META_SUFFIX).toString();
@@ -255,6 +281,10 @@
     return ref.startsWith(REFS_USERS);
   }
 
+  public static boolean isRefsUsersSelf(String ref, boolean isAllUsers) {
+    return isAllUsers && REFS_USERS_SELF.equals(ref);
+  }
+
   /**
    * Whether the ref is a group branch that stores NoteDb data of a group. Returns {@code true} for
    * all refs that start with {@code refs/groups/}.
@@ -271,6 +301,16 @@
     return ref.startsWith(REFS_DELETED_GROUPS);
   }
 
+  /** Returns true if the provided ref is for draft comments. */
+  public static boolean isRefsDraftsComments(String ref) {
+    return ref.startsWith(REFS_DRAFT_COMMENTS);
+  }
+
+  /** Returns true if the provided ref is for starred changes. */
+  public static boolean isRefsStarredChanges(String ref) {
+    return ref.startsWith(REFS_STARRED_CHANGES);
+  }
+
   /**
    * Whether the ref is used for storing group data in NoteDb. Returns {@code true} for all group
    * branches, refs/meta/group-names and deleted group branches.
@@ -292,21 +332,11 @@
    * <p>Any ref for which this method evaluates to true will be served to users who have the {@code
    * ACCESS_DATABASE} capability.
    *
-   * <p><b>Caution</b>Any ref not in this list will be served if the user was granted a READ
+   * <p><b>Caution</b> Any ref not in this list will be served if the user was granted a READ
    * permission on it using Gerrit's permission model.
    */
   public static boolean isGerritRef(String ref) {
-    return ref.startsWith(REFS_CHANGES)
-        || ref.startsWith(REFS_EXTERNAL_IDS)
-        || ref.startsWith(REFS_CACHE_AUTOMERGE)
-        || ref.startsWith(REFS_DRAFT_COMMENTS)
-        || ref.startsWith(REFS_DELETED_GROUPS)
-        || ref.startsWith(REFS_SEQUENCES)
-        || ref.startsWith(REFS_GROUPS)
-        || ref.startsWith(REFS_GROUPNAMES)
-        || ref.startsWith(REFS_USERS)
-        || ref.startsWith(REFS_STARRED_CHANGES)
-        || ref.startsWith(REFS_REJECT_COMMITS);
+    return GERRIT_REFS.stream().anyMatch(internalRef -> ref.startsWith(internalRef));
   }
 
   static Integer parseShardedRefPart(String name) {
diff --git a/java/com/google/gerrit/entities/SubmitRecord.java b/java/com/google/gerrit/entities/SubmitRecord.java
index 67c6007..860997f 100644
--- a/java/com/google/gerrit/entities/SubmitRecord.java
+++ b/java/com/google/gerrit/entities/SubmitRecord.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.entities;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Objects;
@@ -50,7 +53,11 @@
     FORCED,
 
     /**
-     * An internal server error occurred preventing computation.
+     * A rule error caused by user misconfiguration.
+     *
+     * <p>This status should only be used to signal that the user has misconfigured the submit rule.
+     * In case plugins encounter server exceptions while evaluating the rule, they should throw a
+     * {@link RuntimeException} such as {@link IllegalStateException}.
      *
      * <p>Additional detail may be available in {@link SubmitRecord#errorMessage}.
      */
@@ -63,7 +70,7 @@
 
   public Status status;
   public List<Label> labels;
-  public List<SubmitRequirement> requirements;
+  public List<LegacySubmitRequirement> requirements;
   public String errorMessage;
 
   public static class Label {
@@ -107,6 +114,17 @@
     public Status status;
     public Account.Id appliedBy;
 
+    /**
+     * Returns a new instance of {@link Label} that contains a new instance for each mutable field.
+     */
+    public Label deepCopy() {
+      Label copy = new Label();
+      copy.label = label;
+      copy.status = status;
+      copy.appliedBy = appliedBy;
+      return copy;
+    }
+
     @Override
     public String toString() {
       StringBuilder sb = new StringBuilder();
@@ -134,6 +152,23 @@
     }
   }
 
+  /**
+   * Returns a new instance of {@link SubmitRecord} that contains a new instance for each mutable
+   * field.
+   */
+  public SubmitRecord deepCopy() {
+    SubmitRecord copy = new SubmitRecord();
+    copy.status = status;
+    copy.errorMessage = errorMessage;
+    if (labels != null) {
+      copy.labels = labels.stream().map(Label::deepCopy).collect(toImmutableList());
+    }
+    if (requirements != null) {
+      copy.requirements = ImmutableList.copyOf(requirements);
+    }
+    return copy;
+  }
+
   @Override
   public String toString() {
     StringBuilder sb = new StringBuilder();
@@ -152,7 +187,7 @@
     sb.append("],[");
     if (requirements != null) {
       String delimiter = "";
-      for (SubmitRequirement requirement : requirements) {
+      for (LegacySubmitRequirement requirement : requirements) {
         sb.append(delimiter).append(requirement);
         delimiter = ", ";
       }
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java b/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
similarity index 62%
copy from java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
copy to java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
index 08d6ce7..452192c 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
+++ b/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2019 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,12 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.change;
+package com.google.gerrit.exceptions;
 
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.util.List;
+public class InternalServerWithUserMessageException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
 
-public interface PluginDefinedAttributesFactory {
-  List<PluginDefinedInfo> create(ChangeData cd);
+  public InternalServerWithUserMessageException(String msg, Throwable cause) {
+    super(msg, cause);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/BUILD b/java/com/google/gerrit/extensions/BUILD
index da5dc8b..21949f7 100644
--- a/java/com/google/gerrit/extensions/BUILD
+++ b/java/com/google/gerrit/extensions/BUILD
@@ -1,5 +1,5 @@
 load("@rules_java//java:defs.bzl", "java_binary", "java_library")
-load("//lib:guava.bzl", "GUAVA_DOC_URL")
+load("//tools:nongoogle.bzl", "GUAVA_DOC_URL")
 load("//tools/bzl:javadoc.bzl", "java_doc")
 
 _DOC_VERS = "5.5.0.201909110433-r"
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 3364fc1..7cbfebd 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInfoDifference;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
@@ -32,6 +33,7 @@
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
@@ -260,6 +262,46 @@
         EnumSet.complementOf(EnumSet.of(ListChangesOption.CHECK, ListChangesOption.SKIP_DIFFSTAT)));
   }
 
+  default ChangeInfoDifference metaDiff(
+      @Nullable String oldMetaRevId, @Nullable String newMetaRevId) throws RestApiException {
+    return metaDiff(
+        oldMetaRevId,
+        newMetaRevId,
+        EnumSet.noneOf(ListChangesOption.class),
+        ImmutableListMultimap.of());
+  }
+
+  default ChangeInfoDifference metaDiff(
+      @Nullable String oldMetaRevId, @Nullable String newMetaRevId, ListChangesOption... options)
+      throws RestApiException {
+    return metaDiff(oldMetaRevId, newMetaRevId, Arrays.asList(options));
+  }
+
+  default ChangeInfoDifference metaDiff(
+      @Nullable String oldMetaRevId,
+      @Nullable String newMetaRevId,
+      Collection<ListChangesOption> options)
+      throws RestApiException {
+    return metaDiff(
+        oldMetaRevId,
+        newMetaRevId,
+        Sets.newEnumSet(options, ListChangesOption.class),
+        ImmutableListMultimap.of());
+  }
+
+  /**
+   * Gets the diff between a change's metadata with the two given refs.
+   *
+   * @param oldMetaRevId the SHA-1 of the 'before' metadata diffed against {@code newMetaRevId}
+   * @param newMetaRevId the SHA-1 of the 'after' metadata diffed against {@code oldMetaRevId}
+   */
+  ChangeInfoDifference metaDiff(
+      @Nullable String oldMetaRevId,
+      @Nullable String newMetaRevId,
+      EnumSet<ListChangesOption> options,
+      ImmutableListMultimap<String, String> pluginOptions)
+      throws RestApiException;
+
   /** {@link #get(ListChangesOption...)} with no options included. */
   default ChangeInfo info() throws RestApiException {
     return get(EnumSet.noneOf(ListChangesOption.class));
@@ -370,7 +412,9 @@
    *     their patch set.
    * @throws RestApiException
    */
-  Map<String, List<CommentInfo>> drafts() throws RestApiException;
+  default Map<String, List<CommentInfo>> drafts() throws RestApiException {
+    return draftsRequest().get();
+  }
 
   /**
    * Get all draft comments for the current user on a change as a list.
@@ -379,7 +423,17 @@
    *     set.
    * @throws RestApiException
    */
-  List<CommentInfo> draftsAsList() throws RestApiException;
+  default List<CommentInfo> draftsAsList() throws RestApiException {
+    return draftsRequest().getAsList();
+  }
+
+  /**
+   * Get a {@link DraftsRequest} entity that can be used to retrieve draft comments.
+   *
+   * @return A {@link DraftsRequest} entity that can be used to retrieve the draft comments using
+   *     {@link DraftsRequest#get()} or {@link DraftsRequest#getAsList()}.
+   */
+  DraftsRequest draftsRequest() throws RestApiException;
 
   ChangeInfo check() throws RestApiException;
 
@@ -413,6 +467,7 @@
 
   abstract class CommentsRequest {
     private boolean enableContext;
+    private int contextPadding;
 
     /**
      * Get all published comments on a change.
@@ -436,6 +491,11 @@
       return this;
     }
 
+    public CommentsRequest contextPadding(int contextPadding) {
+      this.contextPadding = contextPadding;
+      return this;
+    }
+
     public CommentsRequest withContext() {
       this.enableContext = true;
       return this;
@@ -444,8 +504,14 @@
     public boolean getContext() {
       return enableContext;
     }
+
+    public int getContextPadding() {
+      return contextPadding;
+    }
   }
 
+  abstract class DraftsRequest extends CommentsRequest {}
+
   abstract class SuggestedReviewersRequest {
     private String query;
     private int limit;
@@ -604,6 +670,16 @@
     }
 
     @Override
+    public ChangeInfoDifference metaDiff(
+        @Nullable String oldMetaRevId,
+        @Nullable String newMetaRevId,
+        EnumSet<ListChangesOption> options,
+        ImmutableListMultimap<String, String> pluginOptions)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void setMessage(CommitMessageInput in) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -686,6 +762,11 @@
     }
 
     @Override
+    public DraftsRequest draftsRequest() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ChangeInfo check() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/MoveInput.java b/java/com/google/gerrit/extensions/api/changes/MoveInput.java
index 795642a..3d82990 100644
--- a/java/com/google/gerrit/extensions/api/changes/MoveInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/MoveInput.java
@@ -17,4 +17,14 @@
 public class MoveInput {
   public String message;
   public String destinationBranch;
+  /**
+   * Whether or not to keep all votes in the destination branch. Keeping the votes can be confusing
+   * in the context of the destination branch, see
+   * https://gerrit-review.googlesource.com/c/gerrit/+/129171. That is why only the users with
+   * {@link com.google.gerrit.server.permissions.GlobalPermission#ADMINISTRATE_SERVER} permissions
+   * can use this option.
+   *
+   * <p>By default, only the veto votes that are blocking the change from submission are moved.
+   */
+  public boolean keepAllVotes;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
index 5f4a014..10559a3 100644
--- a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
@@ -16,4 +16,12 @@
 
 public class RebaseInput {
   public String base;
+
+  /**
+   * Whether the rebase should succeed if there are conflicts.
+   *
+   * <p>If there are conflicts the file contents of the rebased change contain git conflict markers
+   * to indicate the conflicts.
+   */
+  public boolean allowConflicts;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index b419c2f..73e6a4e 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -68,6 +68,8 @@
 
   ChangeApi rebase(RebaseInput in) throws RestApiException;
 
+  ChangeInfo rebaseAsInfo(RebaseInput in) throws RestApiException;
+
   boolean canRebase() throws RestApiException;
 
   RevisionReviewerApi reviewer(String id) throws RestApiException;
@@ -218,6 +220,11 @@
     }
 
     @Override
+    public ChangeInfo rebaseAsInfo(RebaseInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public boolean canRebase() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java b/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
index fab2ec4..423ac49 100644
--- a/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
+++ b/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
@@ -14,10 +14,15 @@
 
 package com.google.gerrit.extensions.api.config;
 
+import java.util.List;
+
 public class AccessCheckInfo {
   public String message;
   // HTTP status code
   public int status;
 
+  /** Debug logs that may help to understand why a permission is denied or allowed. */
+  public List<String> debugLogs;
+
   // for future extension, we may add inputs / results for bulk checks.
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/CommitApi.java b/java/com/google/gerrit/extensions/api/projects/CommitApi.java
index a53fc74..b0cc9da 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommitApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommitApi.java
@@ -18,8 +18,10 @@
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.Map;
 
 public interface CommitApi {
   CommitInfo get() throws RestApiException;
@@ -28,6 +30,9 @@
 
   IncludedInInfo includedIn() throws RestApiException;
 
+  /** List files in a specific commit against the parent commit. */
+  Map<String, FileInfo> files(int parentNum) throws RestApiException;
+
   /** A default implementation for source compatibility when adding new methods to the interface. */
   class NotImplemented implements CommitApi {
     @Override
@@ -44,5 +49,10 @@
     public IncludedInInfo includedIn() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public Map<String, FileInfo> files(int parentNum) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 30514a6..21b319e 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -27,11 +27,12 @@
 
   /** Preferred method to download a change. */
   public enum DownloadCommand {
-    REPO_DOWNLOAD,
     PULL,
     CHECKOUT,
     CHERRY_PICK,
-    FORMAT_PATCH
+    FORMAT_PATCH,
+    BRANCH,
+    RESET,
   }
 
   public enum DateFormat {
@@ -146,6 +147,7 @@
   public EmailFormat emailFormat;
   public DefaultBase defaultBaseForMerges;
   public Boolean publishCommentsOnPush;
+  public Boolean disableKeyboardShortcuts;
   public Boolean workInProgressByDefault;
   public List<MenuItem> my;
   public List<String> changeTable;
@@ -204,6 +206,7 @@
     p.emailFormat = EmailFormat.HTML_PLAINTEXT;
     p.defaultBaseForMerges = DefaultBase.FIRST_PARENT;
     p.publishCommentsOnPush = false;
+    p.disableKeyboardShortcuts = false;
     p.workInProgressByDefault = false;
     return p;
   }
diff --git a/java/com/google/gerrit/extensions/common/ActionInfo.java b/java/com/google/gerrit/extensions/common/ActionInfo.java
index 6ab80b2..2144ed5 100644
--- a/java/com/google/gerrit/extensions/common/ActionInfo.java
+++ b/java/com/google/gerrit/extensions/common/ActionInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.webui.UiAction;
+import java.util.Objects;
 
 /**
  * Representation of an action in the REST API.
@@ -55,4 +56,23 @@
     title = d.getTitle();
     enabled = d.isEnabled() ? true : null;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof ActionInfo) {
+      ActionInfo actionInfo = (ActionInfo) o;
+      return Objects.equals(method, actionInfo.method)
+          && Objects.equals(label, actionInfo.label)
+          && Objects.equals(title, actionInfo.title)
+          && Objects.equals(enabled, actionInfo.enabled);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(method, label, title, enabled);
+  }
+
+  protected ActionInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index f95ddff..bf72e83 100644
--- a/java/com/google/gerrit/extensions/common/ApprovalInfo.java
+++ b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.util.Objects;
 
 /**
  * Representation of an approval in the REST API.
@@ -71,4 +72,23 @@
     this.date = date;
     this.tag = tag;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof ApprovalInfo) {
+      ApprovalInfo approvalInfo = (ApprovalInfo) o;
+      return super.equals(o)
+          && Objects.equals(tag, approvalInfo.tag)
+          && Objects.equals(value, approvalInfo.value)
+          && Objects.equals(date, approvalInfo.date)
+          && Objects.equals(postSubmit, approvalInfo.postSubmit)
+          && Objects.equals(permittedVotingRange, approvalInfo.permittedVotingRange);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(super.hashCode(), tag, value, date, postSubmit, permittedVotingRange);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/AttentionSetInfo.java b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
index f29d32b..ba865fb 100644
--- a/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
+++ b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.util.Objects;
 
 /**
  * Represents a single user included in the attention set. Used in the API. See {@link
@@ -36,4 +37,22 @@
     this.lastUpdate = lastUpdate;
     this.reason = reason;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof AttentionSetInfo) {
+      AttentionSetInfo attentionSetInfo = (AttentionSetInfo) o;
+      return Objects.equals(account, attentionSetInfo.account)
+          && Objects.equals(lastUpdate, attentionSetInfo.lastUpdate)
+          && Objects.equals(reason, attentionSetInfo.reason);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(account, lastUpdate, reason);
+  }
+
+  protected AttentionSetInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/AvatarInfo.java b/java/com/google/gerrit/extensions/common/AvatarInfo.java
index 75665a8..b620ac2 100644
--- a/java/com/google/gerrit/extensions/common/AvatarInfo.java
+++ b/java/com/google/gerrit/extensions/common/AvatarInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 /**
  * Representation of an avatar in the REST API.
  *
@@ -38,4 +40,20 @@
 
   /** The width of the avatar image in pixels. */
   public Integer width;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof AvatarInfo) {
+      AvatarInfo avatarInfo = (AvatarInfo) o;
+      return Objects.equals(url, avatarInfo.url)
+          && Objects.equals(height, avatarInfo.height)
+          && Objects.equals(width, avatarInfo.width);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(url, height, width);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
index a441bfd..b387017 100644
--- a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -19,7 +19,6 @@
   public Boolean allowBlame;
   public Boolean showAssigneeInChangesTable;
   public Boolean disablePrivateChanges;
-  public int largeChange;
   public String replyLabel;
   public String replyTooltip;
   public int updateDelay;
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 190a97e..9e915f5 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -70,6 +72,7 @@
   public String submissionId;
   public Integer cherryPickOfChange;
   public Integer cherryPickOfPatchSet;
+  public String metaRevId;
 
   /**
    * Whether the change contains conflicts.
@@ -83,11 +86,12 @@
    * com.google.gerrit.server.restapi.change.CreateChange}, {@link
    * com.google.gerrit.server.restapi.change.CreateMergePatchSet}, {@link
    * com.google.gerrit.server.restapi.change.CherryPick}, {@link
-   * com.google.gerrit.server.restapi.change.CherryPickCommit}
+   * com.google.gerrit.server.restapi.change.CherryPickCommit}, {@link
+   * com.google.gerrit.server.restapi.change.Rebase}
    */
   public Boolean containsGitConflicts;
 
-  public int _number;
+  public Integer _number;
 
   public AccountInfo owner;
 
@@ -107,5 +111,15 @@
   public List<ProblemInfo> problems;
   public List<PluginDefinedInfo> plugins;
   public Collection<TrackingIdInfo> trackingIds;
-  public Collection<SubmitRequirementInfo> requirements;
+  public Collection<LegacySubmitRequirementInfo> requirements;
+
+  public ChangeInfo() {}
+
+  public ChangeInfo(ChangeMessageInfo... messages) {
+    this.messages = ImmutableList.copyOf(messages);
+  }
+
+  public ChangeInfo(Map<String, RevisionInfo> revisions) {
+    this.revisions = ImmutableMap.copyOf(revisions);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
new file mode 100644
index 0000000..0fff0ba
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
@@ -0,0 +1,203 @@
+// 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 static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.Arrays.stream;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Gets the differences between two {@link ChangeInfo}s.
+ *
+ * <p>This must be in package {@code com.google.gerrit.extensions.common} for access to protected
+ * constructors.
+ *
+ * <p>This assumes that every class reachable from {@link ChangeInfo} has a non-private constructor
+ * with zero parameters and overrides the equals method.
+ */
+public final class ChangeInfoDiffer {
+
+  /**
+   * Returns the difference between two instances of {@link ChangeInfo}.
+   *
+   * <p>The {@link ChangeInfoDifference} returned has the following properties:
+   *
+   * <p>Unrepeated fields are present in the difference returned when they differ between {@code
+   * oldChangeInfo} and {@code newChangeInfo}. When there's an unrepeated field that's not a {@link
+   * String}, primitive, or enum, its fields are only returned when they differ.
+   *
+   * <p>Entries in {@link Map} fields are returned when a key is present in {@code newChangeInfo}
+   * and not {@code oldChangeInfo}. If a key is present in both, the diff of the value is returned.
+   *
+   * <p>{@link Collection} fields in {@link ChangeInfoDifference#added()} contain only items found
+   * in {@code newChangeInfo} and not {@code oldChangeInfo}.
+   *
+   * <p>{@link Collection} fields in {@link ChangeInfoDifference#removed()} contain only items found
+   * in {@code oldChangeInfo} and not {@code newChangeInfo}.
+   *
+   * @param oldChangeInfo the previous {@link ChangeInfo} to diff against {@code newChangeInfo}
+   * @param newChangeInfo the {@link ChangeInfo} to diff against {@code oldChangeInfo}
+   * @return the difference between the given {@link ChangeInfo}s
+   */
+  public static ChangeInfoDifference getDifference(
+      ChangeInfo oldChangeInfo, ChangeInfo newChangeInfo) {
+    return ChangeInfoDifference.create(
+        /* added= */ getAdded(oldChangeInfo, newChangeInfo),
+        /* removed= */ getAdded(newChangeInfo, oldChangeInfo));
+  }
+
+  @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);
+      return (T) result;
+    }
+
+    if (newValue instanceof Map) {
+      Map result = getAddedForMap((Map<?, ?>) oldValue, (Map<?, ?>) newValue);
+      return (T) result;
+    }
+
+    T toPopulate = (T) construct(newValue.getClass());
+    if (toPopulate == null) {
+      return null;
+    }
+
+    for (Field field : newValue.getClass().getDeclaredFields()) {
+      if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) {
+        continue;
+      }
+
+      Object newFieldObj = get(field, newValue);
+      if (oldValue == null || newFieldObj == null) {
+        set(field, toPopulate, newFieldObj);
+        continue;
+      }
+
+      Object oldFieldObj = get(field, oldValue);
+      if (newFieldObj.equals(oldFieldObj)) {
+        continue;
+      }
+
+      if (isSimple(field.getType()) || oldFieldObj == null) {
+        set(field, toPopulate, newFieldObj);
+      } else if (newFieldObj instanceof Collection || newFieldObj instanceof Map) {
+        set(field, toPopulate, getAdded(oldFieldObj, newFieldObj));
+      } else {
+        // Recurse to set all fields in the non-primitive object.
+        set(field, toPopulate, getAdded(oldFieldObj, newFieldObj));
+      }
+    }
+    return toPopulate;
+  }
+
+  @VisibleForTesting
+  static boolean isSimple(Class<?> c) {
+    return c.isPrimitive()
+        || c.isEnum()
+        || String.class.isAssignableFrom(c)
+        || Number.class.isAssignableFrom(c)
+        || Boolean.class.isAssignableFrom(c)
+        || Timestamp.class.isAssignableFrom(c);
+  }
+
+  @VisibleForTesting
+  static Object construct(Class<?> c) {
+    // Only use constructors without parameters because we can't determine what values to pass.
+    return stream(c.getDeclaredConstructors())
+        .filter(constructor -> constructor.getParameterCount() == 0)
+        .findAny()
+        .map(ChangeInfoDiffer::construct)
+        .orElseThrow(
+            () ->
+                new IllegalStateException("Class " + c + " must have a zero argument constructor"));
+  }
+
+  private static Object construct(Constructor<?> constructor) {
+    try {
+      return constructor.newInstance();
+    } catch (ReflectiveOperationException e) {
+      throw new IllegalStateException("Failed to construct class " + constructor.getName(), e);
+    }
+  }
+
+  /** @return {@code null} if nothing has been added to {@code oldCollection} */
+  private static ImmutableList<?> getAddedForCollection(
+      Collection<?> oldCollection, Collection<?> newCollection) {
+    ImmutableList<?> notInOldCollection = getAdditions(oldCollection, newCollection);
+    return notInOldCollection.isEmpty() ? null : notInOldCollection;
+  }
+
+  private static ImmutableList<Object> getAdditions(
+      Collection<?> oldCollection, Collection<?> newCollection) {
+    if (oldCollection == null)
+      return newCollection != null ? ImmutableList.copyOf(newCollection) : null;
+
+    Map<Object, List<Object>> duplicatesMap = newCollection.stream().collect(groupingBy(v -> v));
+    oldCollection.forEach(
+        v -> {
+          if (duplicatesMap.containsKey(v)) {
+            duplicatesMap.get(v).remove(v);
+          }
+        });
+    return duplicatesMap.values().stream().flatMap(Collection::stream).collect(toImmutableList());
+  }
+
+  /** @return {@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()) {
+      Object added = getAdded(oldMap.get(entry.getKey()), entry.getValue());
+      if (added != null) {
+        additionsBuilder.put(entry.getKey(), added);
+      }
+    }
+    ImmutableMap<Object, Object> additions = additionsBuilder.build();
+    return additions.isEmpty() ? null : additions;
+  }
+
+  private static Object get(Field field, Object obj) {
+    try {
+      return field.get(obj);
+    } catch (IllegalAccessException e) {
+      throw new IllegalStateException(
+          String.format("Access denied getting field %s in %s", field.getName(), obj.getClass()),
+          e);
+    }
+  }
+
+  private static void set(Field field, Object obj, Object value) {
+    try {
+      field.set(obj, value);
+    } catch (IllegalAccessException e) {
+      throw new IllegalStateException(
+          String.format(
+              "Access denied setting field %s in %s", field.getName(), obj.getClass().getName()),
+          e);
+    }
+  }
+
+  private ChangeInfoDiffer() {}
+}
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java b/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java
new file mode 100644
index 0000000..269c673
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDifference.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.extensions.common;
+
+import com.google.auto.value.AutoValue;
+
+/** The difference between two {@link ChangeInfo}s returned by {@link ChangeInfoDiffer}. */
+@AutoValue
+public abstract class ChangeInfoDifference {
+
+  public abstract ChangeInfo added();
+
+  public abstract ChangeInfo removed();
+
+  public static ChangeInfoDifference create(ChangeInfo added, ChangeInfo removed) {
+    return new AutoValue_ChangeInfoDifference(added, removed);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
index 07ad71b..10456ff 100644
--- a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
@@ -26,6 +26,12 @@
   public String message;
   public Integer _revisionNumber;
 
+  public ChangeMessageInfo() {}
+
+  public ChangeMessageInfo(String message) {
+    this.message = message;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof ChangeMessageInfo) {
diff --git a/java/com/google/gerrit/extensions/common/CommentInfo.java b/java/com/google/gerrit/extensions/common/CommentInfo.java
index fcce2b3..35587a0 100644
--- a/java/com/google/gerrit/extensions/common/CommentInfo.java
+++ b/java/com/google/gerrit/extensions/common/CommentInfo.java
@@ -30,6 +30,9 @@
    */
   public List<ContextLineInfo> contextLines;
 
+  /** Mime type of the underlying source file. Only available if context lines are requested. */
+  public String sourceContentType;
+
   @Override
   public boolean equals(Object o) {
     if (super.equals(o)) {
diff --git a/java/com/google/gerrit/extensions/common/FetchInfo.java b/java/com/google/gerrit/extensions/common/FetchInfo.java
index eda84b1..4b1e941 100644
--- a/java/com/google/gerrit/extensions/common/FetchInfo.java
+++ b/java/com/google/gerrit/extensions/common/FetchInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.Map;
+import java.util.Objects;
 
 public class FetchInfo {
   public String url;
@@ -25,4 +26,22 @@
     this.url = url;
     this.ref = ref;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof FetchInfo) {
+      FetchInfo fetchInfo = (FetchInfo) o;
+      return Objects.equals(url, fetchInfo.url)
+          && Objects.equals(ref, fetchInfo.ref)
+          && Objects.equals(commands, fetchInfo.commands);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(url, ref, commands);
+  }
+
+  protected FetchInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/FileInfo.java b/java/com/google/gerrit/extensions/common/FileInfo.java
index 32c5bd5..510c2ad 100644
--- a/java/com/google/gerrit/extensions/common/FileInfo.java
+++ b/java/com/google/gerrit/extensions/common/FileInfo.java
@@ -34,8 +34,8 @@
           && Objects.equals(oldPath, fileInfo.oldPath)
           && Objects.equals(linesInserted, fileInfo.linesInserted)
           && Objects.equals(linesDeleted, fileInfo.linesDeleted)
-          && Objects.equals(sizeDelta, fileInfo.sizeDelta)
-          && Objects.equals(size, fileInfo.size);
+          && sizeDelta == fileInfo.sizeDelta
+          && size == fileInfo.size;
     }
     return false;
   }
diff --git a/java/com/google/gerrit/extensions/common/GerritInfo.java b/java/com/google/gerrit/extensions/common/GerritInfo.java
index 2ae6703..3265a00 100644
--- a/java/com/google/gerrit/extensions/common/GerritInfo.java
+++ b/java/com/google/gerrit/extensions/common/GerritInfo.java
@@ -23,4 +23,5 @@
   public Boolean editGpgKeys;
   public String reportBugUrl;
   public String primaryWeblinkName;
+  public String instanceId;
 }
diff --git a/java/com/google/gerrit/extensions/common/GpgKeyInfo.java b/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
index 7a5c15b..d656f22 100644
--- a/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
+++ b/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.List;
+import java.util.Objects;
 
 public class GpgKeyInfo {
   /**
@@ -43,4 +44,22 @@
 
   public Status status;
   public List<String> problems;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof GpgKeyInfo) {
+      GpgKeyInfo gpgKeyInfo = (GpgKeyInfo) o;
+      return Objects.equals(id, gpgKeyInfo.id)
+          && Objects.equals(fingerprint, gpgKeyInfo.fingerprint)
+          && Objects.equals(userIds, gpgKeyInfo.userIds)
+          && Objects.equals(status, gpgKeyInfo.status)
+          && Objects.equals(problems, gpgKeyInfo.problems);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(id, fingerprint, userIds, status, problems);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
index f552566..9a6d086 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
@@ -28,6 +28,7 @@
   public Boolean copyAnyScore;
   public Boolean copyMinScore;
   public Boolean copyMaxScore;
+  public Boolean copyAllScoresIfListOfFilesDidNotChange;
   public Boolean copyAllScoresIfNoChange;
   public Boolean copyAllScoresIfNoCodeChange;
   public Boolean copyAllScoresOnTrivialRebase;
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
index 23d5df1..87cae86 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
@@ -27,6 +27,7 @@
   public Boolean copyAnyScore;
   public Boolean copyMinScore;
   public Boolean copyMaxScore;
+  public Boolean copyAllScoresIfListOfFilesDidNotChange;
   public Boolean copyAllScoresIfNoChange;
   public Boolean copyAllScoresIfNoCodeChange;
   public Boolean copyAllScoresOnTrivialRebase;
diff --git a/java/com/google/gerrit/extensions/common/LabelInfo.java b/java/com/google/gerrit/extensions/common/LabelInfo.java
index 76dd93d..44bcdaf 100644
--- a/java/com/google/gerrit/extensions/common/LabelInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelInfo.java
@@ -16,6 +16,7 @@
 
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 
 public class LabelInfo {
   public AccountInfo approved;
@@ -30,4 +31,37 @@
   public Short defaultValue;
   public Boolean optional;
   public Boolean blocking;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof LabelInfo) {
+      LabelInfo labelInfo = (LabelInfo) o;
+      return Objects.equals(approved, labelInfo.approved)
+          && Objects.equals(rejected, labelInfo.rejected)
+          && Objects.equals(recommended, labelInfo.recommended)
+          && Objects.equals(disliked, labelInfo.disliked)
+          && Objects.equals(all, labelInfo.all)
+          && Objects.equals(values, labelInfo.values)
+          && Objects.equals(value, labelInfo.value)
+          && Objects.equals(defaultValue, labelInfo.defaultValue)
+          && Objects.equals(optional, labelInfo.optional)
+          && Objects.equals(blocking, labelInfo.blocking);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        approved,
+        rejected,
+        recommended,
+        disliked,
+        all,
+        values,
+        value,
+        defaultValue,
+        optional,
+        blocking);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java b/java/com/google/gerrit/extensions/common/LegacySubmitRequirementInfo.java
similarity index 78%
rename from java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
rename to java/com/google/gerrit/extensions/common/LegacySubmitRequirementInfo.java
index 3483de5..adf01e1 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
+++ b/java/com/google/gerrit/extensions/common/LegacySubmitRequirementInfo.java
@@ -17,12 +17,12 @@
 import com.google.common.base.MoreObjects;
 import java.util.Objects;
 
-public class SubmitRequirementInfo {
-  public final String status;
-  public final String fallbackText;
-  public final String type;
+public class LegacySubmitRequirementInfo {
+  public String status;
+  public String fallbackText;
+  public String type;
 
-  public SubmitRequirementInfo(String status, String fallbackText, String type) {
+  public LegacySubmitRequirementInfo(String status, String fallbackText, String type) {
     this.status = status;
     this.fallbackText = fallbackText;
     this.type = type;
@@ -33,10 +33,10 @@
     if (this == o) {
       return true;
     }
-    if (!(o instanceof SubmitRequirementInfo)) {
+    if (!(o instanceof LegacySubmitRequirementInfo)) {
       return false;
     }
-    SubmitRequirementInfo that = (SubmitRequirementInfo) o;
+    LegacySubmitRequirementInfo that = (LegacySubmitRequirementInfo) o;
     return Objects.equals(status, that.status)
         && Objects.equals(fallbackText, that.fallbackText)
         && Objects.equals(type, that.type);
@@ -55,4 +55,6 @@
         .add("type", type)
         .toString();
   }
+
+  protected LegacySubmitRequirementInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/PluginConfigInfo.java b/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
index 13fc9ec..2d1d840 100644
--- a/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
@@ -19,5 +19,4 @@
 public class PluginConfigInfo {
   public Boolean hasAvatars;
   public List<String> jsResourcePaths;
-  public List<String> htmlResourcePaths;
 }
diff --git a/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
index 69bfa2c..e2b1c36 100644
--- a/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
+++ b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
@@ -14,7 +14,24 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class PluginDefinedInfo {
   public String name;
   public String message;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof PluginDefinedInfo) {
+      PluginDefinedInfo pluginDefinedInfo = (PluginDefinedInfo) o;
+      return Objects.equals(name, pluginDefinedInfo.name)
+          && Objects.equals(message, pluginDefinedInfo.message);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(name, message);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/PushCertificateInfo.java b/java/com/google/gerrit/extensions/common/PushCertificateInfo.java
index 9eed808..199dbd1 100644
--- a/java/com/google/gerrit/extensions/common/PushCertificateInfo.java
+++ b/java/com/google/gerrit/extensions/common/PushCertificateInfo.java
@@ -14,7 +14,24 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class PushCertificateInfo {
   public String certificate;
   public GpgKeyInfo key;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof PushCertificateInfo) {
+      PushCertificateInfo pushCertificateInfo = (PushCertificateInfo) o;
+      return Objects.equals(certificate, pushCertificateInfo.certificate)
+          && Objects.equals(key, pushCertificateInfo.key);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(certificate, key);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
index eccdc64..37e1ceb 100644
--- a/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
+++ b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
@@ -16,10 +16,28 @@
 
 import com.google.gerrit.extensions.client.ReviewerState;
 import java.sql.Timestamp;
+import java.util.Objects;
 
 public class ReviewerUpdateInfo {
   public Timestamp updated;
   public AccountInfo updatedBy;
   public AccountInfo reviewer;
   public ReviewerState state;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof ReviewerUpdateInfo) {
+      ReviewerUpdateInfo reviewerUpdateInfo = (ReviewerUpdateInfo) o;
+      return Objects.equals(updated, reviewerUpdateInfo.updated)
+          && Objects.equals(updatedBy, reviewerUpdateInfo.updatedBy)
+          && Objects.equals(reviewer, reviewerUpdateInfo.reviewer)
+          && Objects.equals(state, reviewerUpdateInfo.state);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(updated, updatedBy, reviewer, state);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
index f262901..f710ab7 100644
--- a/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.client.ChangeKind;
 import java.sql.Timestamp;
 import java.util.Map;
+import java.util.Objects;
 
 public class RevisionInfo {
   // ActionJson#copy(List, RevisionInfo) must be adapted if new fields are added that are not
@@ -34,4 +35,58 @@
   public String commitWithFooters;
   public PushCertificateInfo pushCertificate;
   public String description;
+
+  public RevisionInfo() {}
+
+  public RevisionInfo(String ref) {
+    this.ref = ref;
+  }
+
+  public RevisionInfo(String ref, int number) {
+    this.ref = ref;
+    _number = number;
+  }
+
+  public RevisionInfo(AccountInfo uploader) {
+    this.uploader = uploader;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof RevisionInfo) {
+      RevisionInfo revisionInfo = (RevisionInfo) o;
+      return isCurrent == revisionInfo.isCurrent
+          && Objects.equals(kind, revisionInfo.kind)
+          && _number == revisionInfo._number
+          && Objects.equals(created, revisionInfo.created)
+          && Objects.equals(uploader, revisionInfo.uploader)
+          && Objects.equals(ref, revisionInfo.ref)
+          && Objects.equals(fetch, revisionInfo.fetch)
+          && Objects.equals(commit, revisionInfo.commit)
+          && Objects.equals(files, revisionInfo.files)
+          && Objects.equals(actions, revisionInfo.actions)
+          && Objects.equals(commitWithFooters, revisionInfo.commitWithFooters)
+          && Objects.equals(pushCertificate, revisionInfo.pushCertificate)
+          && Objects.equals(description, revisionInfo.description);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        isCurrent,
+        kind,
+        _number,
+        created,
+        uploader,
+        ref,
+        fetch,
+        commit,
+        files,
+        actions,
+        commitWithFooters,
+        pushCertificate,
+        description);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/TrackingIdInfo.java b/java/com/google/gerrit/extensions/common/TrackingIdInfo.java
index 0c5ed68..3d35e08 100644
--- a/java/com/google/gerrit/extensions/common/TrackingIdInfo.java
+++ b/java/com/google/gerrit/extensions/common/TrackingIdInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class TrackingIdInfo {
   public String system;
   public String id;
@@ -22,4 +24,20 @@
     this.system = system;
     this.id = id;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof TrackingIdInfo) {
+      TrackingIdInfo trackingIdInfo = (TrackingIdInfo) o;
+      return Objects.equals(system, trackingIdInfo.system) && Objects.equals(id, trackingIdInfo.id);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(system, id);
+  }
+
+  protected TrackingIdInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/VotingRangeInfo.java b/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
index 5c35a49..2f7e9e4 100644
--- a/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class VotingRangeInfo {
   public int min;
   public int max;
@@ -22,4 +24,18 @@
     this.min = min;
     this.max = max;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof VotingRangeInfo) {
+      VotingRangeInfo votingRangeInfo = (VotingRangeInfo) o;
+      return min == votingRangeInfo.min && max == votingRangeInfo.max;
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(min, max);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/WebLinkInfo.java b/java/com/google/gerrit/extensions/common/WebLinkInfo.java
index 84fd970..ba12be0 100644
--- a/java/com/google/gerrit/extensions/common/WebLinkInfo.java
+++ b/java/com/google/gerrit/extensions/common/WebLinkInfo.java
@@ -64,4 +64,6 @@
         + target
         + "}";
   }
+
+  protected WebLinkInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/restapi/NeedsParams.java b/java/com/google/gerrit/extensions/restapi/NeedsParams.java
index c6e2151..bb4294f 100644
--- a/java/com/google/gerrit/extensions/restapi/NeedsParams.java
+++ b/java/com/google/gerrit/extensions/restapi/NeedsParams.java
@@ -19,7 +19,9 @@
 /**
  * Optional interface for {@link RestCollection}.
  *
- * <p>Collections that implement this interface can get to know about the request parameters.
+ * <p>Collections that implement this interface can get to know about the request parameters. The
+ * request parameters are passed only if the collection is the endpoint, e.g. {@code
+ * /changes/?q=abc} would trigger, but {@code /changes/100/?q=abc} does not.
  */
 public interface NeedsParams {
   /**
diff --git a/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java b/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
index b8c9d38..a3f156b 100644
--- a/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
+++ b/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
@@ -22,4 +22,13 @@
   public PreconditionFailedException(String msg) {
     super(msg);
   }
+
+  /**
+   * @param msg message to return to the client describing the error.
+   * @cause original cause of the failed precondition.
+   */
+  public PreconditionFailedException(String msg, Throwable cause) {
+    super(msg);
+    initCause(cause);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/webui/FileWebLink.java b/java/com/google/gerrit/extensions/webui/FileWebLink.java
index c03d606..dc386b3 100644
--- a/java/com/google/gerrit/extensions/webui/FileWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/FileWebLink.java
@@ -32,8 +32,9 @@
    *
    * @param projectName Name of the project
    * @param revision Name of the revision (e.g. branch or commit ID)
+   * @param hash SHA-1 of the commit
    * @param fileName Name of the file
    * @return WebLinkInfo that links to project in external service, null if there should be no link.
    */
-  WebLinkInfo getFileWebLink(String projectName, String revision, String fileName);
+  WebLinkInfo getFileWebLink(String projectName, String revision, String hash, String fileName);
 }
diff --git a/java/com/google/gerrit/extensions/webui/ParentWebLink.java b/java/com/google/gerrit/extensions/webui/ParentWebLink.java
index dfc970d..9f2bc6e 100644
--- a/java/com/google/gerrit/extensions/webui/ParentWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/ParentWebLink.java
@@ -30,10 +30,13 @@
    *
    * <p>
    *
-   * @param projectName Name of the project
-   * @param commit Commit sha1 of the parent revision
+   * @param projectName name of the project
+   * @param commit commit sha1 of the parent revision
+   * @param commitMessage the commit messsage of the change
+   * @param branchName target branch of the change
    * @return WebLinkInfo that links to parent commit in external service, null if there should be no
    *     link.
    */
-  WebLinkInfo getParentWebLink(String projectName, String commit);
+  WebLinkInfo getParentWebLink(
+      String projectName, String commit, String commitMessage, String branchName);
 }
diff --git a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
index 93fe8e1..0e8e28e 100644
--- a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
@@ -30,10 +30,13 @@
    *
    * <p>
    *
-   * @param projectName Name of the project
-   * @param commit Commit of the patch set
+   * @param projectName name of the project
+   * @param commit commit of the patch set
+   * @param commitMessage the commit message of the change
+   * @param branchName target branch of the change
    * @return WebLinkInfo that links to patch set in external service, null if there should be no
    *     link.
    */
-  WebLinkInfo getPatchSetWebLink(String projectName, String commit);
+  WebLinkInfo getPatchSetWebLink(
+      String projectName, String commit, String commitMessage, String branchName);
 }
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index ee99702..cd3ebb9 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -32,7 +32,6 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:jgit-servlet",
-        "//lib:jsch",
         "//lib:servlet-api",
         "//lib:soy",
         "//lib/auto:auto-value",
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 3ea73c3..f302095 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PropertyMap;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -162,15 +163,11 @@
   }
 
   @Override
-  public ExternalId.Key getLastLoginExternalId() {
-    return val != null ? val.getExternalId() : null;
-  }
-
-  @Override
   public CurrentUser getUser() {
     if (user == null) {
       if (isSignedIn()) {
-        user = identified.create(val.getAccountId());
+
+        user = identified.create(val.getAccountId(), getUserProperties(val));
       } else {
         user = anonymousProvider.get();
       }
@@ -178,6 +175,15 @@
     return user;
   }
 
+  private static PropertyMap getUserProperties(@Nullable WebSessionManager.Val val) {
+    if (val == null || val.getExternalId() == null) {
+      return PropertyMap.EMPTY;
+    }
+    return PropertyMap.builder()
+        .put(CurrentUser.LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY, val.getExternalId())
+        .build();
+  }
+
   @Override
   public void login(AuthResult res, boolean rememberMe) {
     Account.Id id = res.getAccountId();
@@ -195,7 +201,7 @@
     key = manager.createKey(id);
     val = manager.createVal(key, id, rememberMe, identity, null, null);
     saveCookie();
-    user = identified.create(val.getAccountId());
+    user = identified.create(val.getAccountId(), getUserProperties(val));
   }
 
   /** Set the user account for this current request only. */
@@ -203,7 +209,7 @@
   public void setUserAccountId(Account.Id id) {
     key = new Key("id:" + id);
     val = new Val(id, 0, false, null, 0, null, null);
-    user = identified.runAs(id, user);
+    user = identified.runAs(id, user, PropertyMap.EMPTY);
   }
 
   @Override
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index 3e2d3ef..06f96c5 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -327,7 +327,7 @@
           if (user instanceof AnonymousUser) {
             throw new ServiceNotAuthorizedException();
           }
-          throw new ServiceNotEnabledException(e.getMessage());
+          throw new RepositoryNotFoundException(nameKey.get(), e);
         }
 
         return manager.openRepository(nameKey);
diff --git a/java/com/google/gerrit/httpd/HttpLogoutServlet.java b/java/com/google/gerrit/httpd/HttpLogoutServlet.java
index 1eaaba3..b56f973 100644
--- a/java/com/google/gerrit/httpd/HttpLogoutServlet.java
+++ b/java/com/google/gerrit/httpd/HttpLogoutServlet.java
@@ -73,11 +73,10 @@
 
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-
-    final String sid = webSession.get().getSessionId();
-    final CurrentUser currentUser = webSession.get().getUser();
-    final String what = "sign out";
-    final long when = TimeUtil.nowMs();
+    String sid = webSession.get().getSessionId();
+    CurrentUser currentUser = webSession.get().getUser();
+    String what = "sign out";
+    long when = TimeUtil.nowMs();
 
     try {
       doLogout(req, rsp);
diff --git a/java/com/google/gerrit/httpd/RequestMetricsFilter.java b/java/com/google/gerrit/httpd/RequestMetricsFilter.java
index c7a7540..c97b9ad 100644
--- a/java/com/google/gerrit/httpd/RequestMetricsFilter.java
+++ b/java/com/google/gerrit/httpd/RequestMetricsFilter.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.httpd;
 
+import com.google.gerrit.metrics.proc.ThreadMXBeanFactory;
+import com.google.gerrit.metrics.proc.ThreadMXBeanInterface;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -30,6 +32,8 @@
 
 @Singleton
 public class RequestMetricsFilter implements Filter {
+  public static final String METRICS_CONTEXT = "metrics-context";
+
   public static Module module() {
     return new ServletModule() {
       @Override
@@ -39,6 +43,36 @@
     };
   }
 
+  public static class Context {
+    private static final ThreadMXBeanInterface threadMxBean = ThreadMXBeanFactory.create();
+    private final long startedTotalCpu;
+    private final long startedUserCpu;
+    private final long startedMemory;
+
+    Context() {
+      startedTotalCpu = threadMxBean.getCurrentThreadCpuTime();
+      startedUserCpu = threadMxBean.getCurrentThreadUserTime();
+      startedMemory = threadMxBean.getCurrentThreadAllocatedBytes();
+    }
+
+    /** @return 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 */
+    public long getUserCpuTime() {
+      return (threadMxBean.getCurrentThreadUserTime() - startedUserCpu) / 1_000_000;
+    }
+
+    /** @return memory allocated in bytes for executing request */
+    public long getAllocatedMemory() {
+      return startedMemory == -1
+          ? -1
+          : threadMxBean.getCurrentThreadAllocatedBytes() - startedMemory;
+    }
+  }
+
   private final RequestMetrics metrics;
 
   @Inject
@@ -52,6 +86,7 @@
   @Override
   public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
       throws IOException, ServletException {
+    request.setAttribute(METRICS_CONTEXT, new Context());
     Response rsp = new Response((HttpServletResponse) response, metrics);
 
     chain.doFilter(request, rsp);
diff --git a/java/com/google/gerrit/httpd/WebSession.java b/java/com/google/gerrit/httpd/WebSession.java
index e8b54fe..daf30ff 100644
--- a/java/com/google/gerrit/httpd/WebSession.java
+++ b/java/com/google/gerrit/httpd/WebSession.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.externalids.ExternalId;
 
 public interface WebSession {
   boolean isSignedIn();
@@ -29,8 +28,6 @@
 
   boolean isValidXGerritAuth(String keyIn);
 
-  ExternalId.Key getLastLoginExternalId();
-
   CurrentUser getUser();
 
   void login(AuthResult res, boolean rememberMe);
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index 509a9f1..e20c9b9 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -35,6 +35,7 @@
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.Locale;
+import java.util.Optional;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -124,8 +125,8 @@
   }
 
   private static boolean correctUser(String user, WebSession session) {
-    ExternalId.Key id = session.getLastLoginExternalId();
-    return id != null && id.equals(ExternalId.Key.create(SCHEME_GERRIT, user));
+    Optional<ExternalId.Key> id = session.getUser().getLastLoginExternalIdKey();
+    return id.map(i -> i.equals(ExternalId.Key.create(SCHEME_GERRIT, user))).orElse(false);
   }
 
   String getRemoteUser(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/httpd/auth/oauth/BUILD b/java/com/google/gerrit/httpd/auth/oauth/BUILD
index 11c9295..2bc65de4 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/BUILD
+++ b/java/com/google/gerrit/httpd/auth/oauth/BUILD
@@ -7,6 +7,7 @@
     resources = ["//resources/com/google/gerrit/httpd/auth/oauth"],
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index ea0c148..70ed79b 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
@@ -35,7 +36,6 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.servlet.SessionScoped;
diff --git a/java/com/google/gerrit/httpd/auth/restapi/BUILD b/java/com/google/gerrit/httpd/auth/restapi/BUILD
new file mode 100644
index 0000000..d499768
--- /dev/null
+++ b/java/com/google/gerrit/httpd/auth/restapi/BUILD
@@ -0,0 +1,14 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "restapi",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/auth",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server",
+        "//lib/flogger:api",
+        "//lib/guice",
+    ],
+)
diff --git a/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java b/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
similarity index 96%
rename from java/com/google/gerrit/server/restapi/account/GetOAuthToken.java
rename to java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
index 24682c0..3594c7c 100644
--- a/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java
+++ b/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
@@ -12,9 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.restapi.account;
+package com.google.gerrit.httpd.auth.restapi;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -22,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/java/com/google/gerrit/httpd/auth/restapi/OAuthRestModule.java b/java/com/google/gerrit/httpd/auth/restapi/OAuthRestModule.java
new file mode 100644
index 0000000..508ad89
--- /dev/null
+++ b/java/com/google/gerrit/httpd/auth/restapi/OAuthRestModule.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.auth.restapi;
+
+import static com.google.gerrit.server.account.AccountResource.ACCOUNT_KIND;
+
+import com.google.gerrit.extensions.restapi.RestApiModule;
+
+public class OAuthRestModule extends RestApiModule {
+  @Override
+  protected void configure() {
+    get(ACCOUNT_KIND, "oauthtoken").to(GetOAuthToken.class);
+  }
+}
diff --git a/java/com/google/gerrit/httpd/init/BUILD b/java/com/google/gerrit/httpd/init/BUILD
index 222041a..adfbdcc 100644
--- a/java/com/google/gerrit/httpd/init/BUILD
+++ b/java/com/google/gerrit/httpd/init/BUILD
@@ -5,12 +5,14 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/elasticsearch",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/gpg",
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/httpd/auth/oauth",
         "//java/com/google/gerrit/httpd/auth/openid",
+        "//java/com/google/gerrit/httpd/auth/restapi",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/lucene",
@@ -28,6 +30,7 @@
         "//java/com/google/gerrit/sshd",
         "//lib:guava",
         "//lib:jgit",
+        "//lib:jgit-ssh-apache",
         "//lib:servlet-api",
         "//lib/flogger:api",
         "//lib/guice",
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index e4a86f5..2ed342f 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Splitter;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.auth.AuthModule;
 import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
@@ -36,6 +37,7 @@
 import com.google.gerrit.httpd.WebSshGlueModule;
 import com.google.gerrit.httpd.auth.oauth.OAuthModule;
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
+import com.google.gerrit.httpd.auth.restapi.OAuthRestModule;
 import com.google.gerrit.httpd.plugins.HttpPluginModule;
 import com.google.gerrit.httpd.raw.StaticModule;
 import com.google.gerrit.index.IndexType;
@@ -102,6 +104,7 @@
 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.IndexCommandsModule;
 import com.google.gerrit.sshd.commands.SequenceCommandsModule;
@@ -323,7 +326,7 @@
     if (VersionManager.getOnlineUpgrade(config)) {
       modules.add(new OnlineUpgrader.Module());
     }
-
+    modules.add(new OAuthRestModule());
     modules.add(new RestApiModule());
     modules.add(new SubscriptionGraph.Module());
     modules.add(new SuperprojectUpdateSubmissionListener.Module());
@@ -338,6 +341,7 @@
         });
     modules.add(new DefaultUrlFormatter.Module());
 
+    SshSessionFactoryInitializer.init(config);
     modules.add(SshKeyCacheImpl.module());
     modules.add(
         new AbstractModule() {
@@ -409,6 +413,8 @@
     } else if (authConfig.getAuthType() == AuthType.OAUTH) {
       modules.add(new OAuthModule());
     }
+    modules.add(new AuthModule(authConfig));
+
     modules.add(sysInjector.getInstance(GetUserFilter.Module.class));
 
     // StaticModule contains a "/*" wildcard, place it last.
diff --git a/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java b/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
index d92da18..e3e96df 100644
--- a/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
+++ b/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
@@ -43,8 +43,8 @@
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
     CacheHeaders.setNotCacheable(res);
-    res.setContentLength(0);
     if (user.get().isIdentifiedUser()) {
+      res.setContentLength(0);
       res.setStatus(HttpServletResponse.SC_NO_CONTENT);
     } else {
       res.setStatus(HttpServletResponse.SC_FORBIDDEN);
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 46dde41..8d52f5a 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -20,7 +20,6 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
@@ -31,6 +30,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gson.Gson;
 import com.google.template.soy.data.SanitizedContent;
 import java.net.URI;
@@ -38,21 +38,15 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import java.util.function.Function;
-import org.eclipse.jgit.lib.Config;
 
 /** Helper for generating parts of {@code index.html}. */
 @UsedAt(Project.GOOGLE)
 public class IndexHtmlUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  static final ImmutableSet<String> DEFAULT_EXPERIMENTS =
-      ImmutableSet.of(
-          "UiFeature__patchset_comments", "UiFeature__patchset_choice_for_comment_links");
-
   private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
   /**
    * Returns both static and dynamic parameters of {@code index.html}. The result is to be used when
@@ -60,7 +54,7 @@
    */
   public static ImmutableMap<String, Object> templateData(
       GerritApi gerritApi,
-      Config gerritServerConfig,
+      ExperimentFeatures experimentFeatures,
       String canonicalURL,
       String cdnPath,
       String faviconPath,
@@ -73,14 +67,8 @@
             staticTemplateData(
                 canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer))
         .putAll(dynamicTemplateData(gerritApi, requestedURL));
+    Set<String> enabledExperiments = experimentFeatures.getEnabledExperimentFeatures();
 
-    Set<String> enabledExperiments = new HashSet<>();
-    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "enabled"))
-        .forEach(enabledExperiments::add);
-    DEFAULT_EXPERIMENTS.forEach(enabledExperiments::add);
-    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "disabled"))
-        .forEach(enabledExperiments::remove);
-    experimentData(urlParameterMap).forEach(enabledExperiments::add);
     if (!enabledExperiments.isEmpty()) {
       data.put("enabledExperiments", serializeObject(GSON, enabledExperiments).toString());
     }
diff --git a/java/com/google/gerrit/httpd/raw/IndexServlet.java b/java/com/google/gerrit/httpd/raw/IndexServlet.java
index b2bdf7c..3f2c202 100644
--- a/java/com/google/gerrit/httpd/raw/IndexServlet.java
+++ b/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.template.soy.SoyFileSet;
 import com.google.template.soy.data.SanitizedContent;
 import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
@@ -34,7 +35,6 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.lib.Config;
 
 public class IndexServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
@@ -43,7 +43,7 @@
   @Nullable private final String cdnPath;
   @Nullable private final String faviconPath;
   private final GerritApi gerritApi;
-  private final Config gerritServerConfig;
+  private final ExperimentFeatures experimentFeatures;
   private final SoySauce soySauce;
   private final Function<String, SanitizedContent> urlOrdainer;
 
@@ -52,12 +52,12 @@
       @Nullable String cdnPath,
       @Nullable String faviconPath,
       GerritApi gerritApi,
-      Config gerritServerConfig) {
+      ExperimentFeatures experimentFeatures) {
     this.canonicalUrl = canonicalUrl;
     this.cdnPath = cdnPath;
     this.faviconPath = faviconPath;
     this.gerritApi = gerritApi;
-    this.gerritServerConfig = gerritServerConfig;
+    this.experimentFeatures = experimentFeatures;
     this.soySauce =
         SoyFileSet.builder()
             .add(Resources.getResource("com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy"))
@@ -79,7 +79,7 @@
       ImmutableMap<String, Object> templateData =
           IndexHtmlUtil.templateData(
               gerritApi,
-              gerritServerConfig,
+              experimentFeatures,
               canonicalUrl,
               cdnPath,
               faviconPath,
diff --git a/java/com/google/gerrit/httpd/raw/SshInfoServlet.java b/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
index 1605360..ec67b8b 100644
--- a/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
+++ b/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
@@ -16,11 +16,11 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.server.ssh.HostKey;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gerrit.util.http.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import com.jcraft.jsch.HostKey;
 import java.io.IOException;
 import java.io.PrintWriter;
 import java.util.List;
@@ -59,14 +59,14 @@
 
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-    final List<HostKey> hostKeys = sshd.getHostKeys();
-    final String out;
+    List<HostKey> hostKeys = sshd.getHostKeys();
+    String out;
     if (!hostKeys.isEmpty()) {
       String host = hostKeys.get(0).getHost();
       String port = "22";
 
       if (host.contains(":")) {
-        final int p = host.lastIndexOf(':');
+        int p = host.lastIndexOf(':');
         port = host.substring(p + 1);
         host = host.substring(0, p);
       }
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 13327ca..bb1eb92 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.inject.Inject;
 import com.google.inject.Key;
 import com.google.inject.Provides;
@@ -222,11 +223,12 @@
     HttpServlet getPolyGerritUiIndexServlet(
         @CanonicalWebUrl @Nullable String canonicalUrl,
         @GerritServerConfig Config cfg,
-        GerritApi gerritApi) {
+        GerritApi gerritApi,
+        ExperimentFeatures experimentFeatures) {
       String cdnPath =
           options.useDevCdn() ? options.devCdn() : cfg.getString("gerrit", null, "cdnPath");
       String faviconPath = cfg.getString("gerrit", null, "faviconPath");
-      return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi, cfg);
+      return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi, experimentFeatures);
     }
 
     @Provides
diff --git a/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index 172321d..3ab409e 100644
--- a/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -32,7 +32,6 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.Url;
@@ -44,7 +43,7 @@
 import com.google.gson.JsonObject;
 import com.google.gson.JsonPrimitive;
 import com.google.inject.Inject;
-import com.google.inject.Injector;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.io.StringWriter;
 import java.util.HashSet;
@@ -149,24 +148,32 @@
   }
 
   private final CmdLineParser.Factory parserFactory;
-  private final Injector injector;
-  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
-  ParameterParser(
-      CmdLineParser.Factory pf,
-      Injector injector,
-      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
+  ParameterParser(CmdLineParser.Factory pf) {
     this.parserFactory = pf;
-    this.injector = injector;
-    this.dynamicBeans = dynamicBeans;
   }
 
+  /**
+   * Parses query parameters ({@code in}) into annotated option fields of {@code param}.
+   *
+   * @return true if parsing was successful. Requesting help is considered failure and returns
+   *     false.
+   */
   <T> boolean parse(
-      T param, ListMultimap<String, String> in, HttpServletRequest req, HttpServletResponse res)
+      T param,
+      DynamicOptions pluginOptions,
+      ListMultimap<String, String> in,
+      HttpServletRequest req,
+      HttpServletResponse res)
       throws IOException {
+    if (param.getClass().getAnnotation(Singleton.class) != null) {
+      // Command-line parsing mutates the object, so we can't have options on @Singleton.
+      return true;
+    }
     CmdLineParser clp = parserFactory.create(param);
-    DynamicOptions pluginOptions = new DynamicOptions(param, injector, dynamicBeans);
+    pluginOptions.setBean(param);
+    pluginOptions.startLifecycleListeners();
     pluginOptions.parseDynamicBeans(clp);
     pluginOptions.setDynamicBeans();
     pluginOptions.onBeanParseStart();
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index d427caa..9b86a4f 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -102,6 +102,7 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.OptionUtil;
 import com.google.gerrit.server.RequestInfo;
@@ -144,6 +145,7 @@
 import com.google.gson.stream.JsonWriter;
 import com.google.gson.stream.MalformedJsonException;
 import com.google.inject.Inject;
+import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
 import com.google.inject.util.Providers;
@@ -248,6 +250,8 @@
     final ChangeFinder changeFinder;
     final RetryHelper retryHelper;
     final PluginSetContext<ExceptionHook> exceptionHooks;
+    final Injector injector;
+    final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
     @Inject
     Globals(
@@ -263,7 +267,9 @@
         DynamicSet<PerformanceLogger> performanceLoggers,
         ChangeFinder changeFinder,
         RetryHelper retryHelper,
-        PluginSetContext<ExceptionHook> exceptionHooks) {
+        PluginSetContext<ExceptionHook> exceptionHooks,
+        Injector injector,
+        DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
@@ -278,6 +284,8 @@
       this.retryHelper = retryHelper;
       this.exceptionHooks = exceptionHooks;
       allowOrigin = makeAllowOrigin(config);
+      this.injector = injector;
+      this.dynamicBeans = dynamicBeans;
     }
 
     private static Pattern makeAllowOrigin(Config cfg) {
@@ -496,105 +504,116 @@
             return;
           }
 
-          if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
-            return;
+          try (DynamicOptions pluginOptions =
+              new DynamicOptions(globals.injector, globals.dynamicBeans)) {
+            if (!globals
+                .paramParser
+                .get()
+                .parse(viewData.view, pluginOptions, qp.params(), req, res)) {
+              return;
+            }
+
+            if (viewData.view instanceof RestReadView<?> && isRead(req)) {
+              response =
+                  invokeRestReadViewWithRetry(
+                      req,
+                      traceContext,
+                      viewData,
+                      (RestReadView<RestResource>) viewData.view,
+                      rsrc);
+            } else if (viewData.view instanceof RestModifyView<?, ?>) {
+              @SuppressWarnings("unchecked")
+              RestModifyView<RestResource, Object> m =
+                  (RestModifyView<RestResource, Object>) viewData.view;
+
+              Type type = inputType(m);
+              inputRequestBody = parseRequest(req, type);
+              response =
+                  invokeRestModifyViewWithRetry(
+                      req, traceContext, viewData, m, rsrc, inputRequestBody);
+
+              if (inputRequestBody instanceof RawInput) {
+                try (InputStream is = req.getInputStream()) {
+                  ServletUtils.consumeRequestBody(is);
+                }
+              }
+            } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
+              @SuppressWarnings("unchecked")
+              RestCollectionCreateView<RestResource, RestResource, Object> m =
+                  (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
+
+              Type type = inputType(m);
+              inputRequestBody = parseRequest(req, type);
+              response =
+                  invokeRestCollectionCreateViewWithRetry(
+                      req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
+              if (inputRequestBody instanceof RawInput) {
+                try (InputStream is = req.getInputStream()) {
+                  ServletUtils.consumeRequestBody(is);
+                }
+              }
+            } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
+              @SuppressWarnings("unchecked")
+              RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
+                  (RestCollectionDeleteMissingView<RestResource, RestResource, Object>)
+                      viewData.view;
+
+              Type type = inputType(m);
+              inputRequestBody = parseRequest(req, type);
+              response =
+                  invokeRestCollectionDeleteMissingViewWithRetry(
+                      req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
+              if (inputRequestBody instanceof RawInput) {
+                try (InputStream is = req.getInputStream()) {
+                  ServletUtils.consumeRequestBody(is);
+                }
+              }
+            } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
+              @SuppressWarnings("unchecked")
+              RestCollectionModifyView<RestResource, RestResource, Object> m =
+                  (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
+
+              Type type = inputType(m);
+              inputRequestBody = parseRequest(req, type);
+              response =
+                  invokeRestCollectionModifyViewWithRetry(
+                      req, traceContext, viewData, m, rsrc, inputRequestBody);
+              if (inputRequestBody instanceof RawInput) {
+                try (InputStream is = req.getInputStream()) {
+                  ServletUtils.consumeRequestBody(is);
+                }
+              }
+            } else {
+              throw new ResourceNotFoundException();
+            }
+
+            if (response instanceof Response.Redirect) {
+              CacheHeaders.setNotCacheable(res);
+              String location = ((Response.Redirect) response).location();
+              res.sendRedirect(location);
+              logger.atFinest().log("REST call redirected to: %s", location);
+              return;
+            } else if (response instanceof Response.Accepted) {
+              CacheHeaders.setNotCacheable(res);
+              res.setStatus(response.statusCode());
+              res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) response).location());
+              logger.atFinest().log("REST call succeeded: %d", response.statusCode());
+              return;
+            }
+
+            statusCode = response.statusCode();
+            configureCaching(req, res, traceContext, rsrc, viewData, response.caching());
+            res.setStatus(statusCode);
+            logger.atFinest().log("REST call succeeded: %d", statusCode);
           }
 
-          if (viewData.view instanceof RestReadView<?> && isRead(req)) {
-            response =
-                invokeRestReadViewWithRetry(
-                    req, traceContext, viewData, (RestReadView<RestResource>) viewData.view, rsrc);
-          } else if (viewData.view instanceof RestModifyView<?, ?>) {
-            @SuppressWarnings("unchecked")
-            RestModifyView<RestResource, Object> m =
-                (RestModifyView<RestResource, Object>) viewData.view;
-
-            Type type = inputType(m);
-            inputRequestBody = parseRequest(req, type);
-            response =
-                invokeRestModifyViewWithRetry(
-                    req, traceContext, viewData, m, rsrc, inputRequestBody);
-
-            if (inputRequestBody instanceof RawInput) {
-              try (InputStream is = req.getInputStream()) {
-                ServletUtils.consumeRequestBody(is);
-              }
+          if (response != Response.none()) {
+            Object value = Response.unwrap(response);
+            if (value instanceof BinaryResult) {
+              responseBytes = replyBinaryResult(req, res, (BinaryResult) value);
+            } else {
+              responseBytes = replyJson(req, res, false, qp.config(), value);
             }
-          } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
-            @SuppressWarnings("unchecked")
-            RestCollectionCreateView<RestResource, RestResource, Object> m =
-                (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
-
-            Type type = inputType(m);
-            inputRequestBody = parseRequest(req, type);
-            response =
-                invokeRestCollectionCreateViewWithRetry(
-                    req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
-            if (inputRequestBody instanceof RawInput) {
-              try (InputStream is = req.getInputStream()) {
-                ServletUtils.consumeRequestBody(is);
-              }
-            }
-          } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
-            @SuppressWarnings("unchecked")
-            RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
-                (RestCollectionDeleteMissingView<RestResource, RestResource, Object>) viewData.view;
-
-            Type type = inputType(m);
-            inputRequestBody = parseRequest(req, type);
-            response =
-                invokeRestCollectionDeleteMissingViewWithRetry(
-                    req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
-            if (inputRequestBody instanceof RawInput) {
-              try (InputStream is = req.getInputStream()) {
-                ServletUtils.consumeRequestBody(is);
-              }
-            }
-          } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
-            @SuppressWarnings("unchecked")
-            RestCollectionModifyView<RestResource, RestResource, Object> m =
-                (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
-
-            Type type = inputType(m);
-            inputRequestBody = parseRequest(req, type);
-            response =
-                invokeRestCollectionModifyViewWithRetry(
-                    req, traceContext, viewData, m, rsrc, inputRequestBody);
-            if (inputRequestBody instanceof RawInput) {
-              try (InputStream is = req.getInputStream()) {
-                ServletUtils.consumeRequestBody(is);
-              }
-            }
-          } else {
-            throw new ResourceNotFoundException();
-          }
-
-          if (response instanceof Response.Redirect) {
-            CacheHeaders.setNotCacheable(res);
-            String location = ((Response.Redirect) response).location();
-            res.sendRedirect(location);
-            logger.atFinest().log("REST call redirected to: %s", location);
-            return;
-          } else if (response instanceof Response.Accepted) {
-            CacheHeaders.setNotCacheable(res);
-            res.setStatus(response.statusCode());
-            res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) response).location());
-            logger.atFinest().log("REST call succeeded: %d", response.statusCode());
-            return;
-          }
-
-          statusCode = response.statusCode();
-          configureCaching(req, res, traceContext, rsrc, viewData, response.caching());
-          res.setStatus(statusCode);
-          logger.atFinest().log("REST call succeeded: %d", statusCode);
-        }
-
-        if (response != Response.none()) {
-          Object value = Response.unwrap(response);
-          if (value instanceof BinaryResult) {
-            responseBytes = replyBinaryResult(req, res, (BinaryResult) value);
-          } else {
-            responseBytes = replyJson(req, res, false, qp.config(), value);
           }
         }
       } catch (MalformedJsonException | JsonParseException e) {
@@ -1633,9 +1652,6 @@
           "Invalid authentication method. In order to authenticate, "
               + "prefix the REST endpoint URL with /a/ (e.g. http://example.com/a/projects/).");
     }
-    if (user.isIdentifiedUser()) {
-      user.setLastLoginExternalIdKey(globals.webSession.get().getLastLoginExternalId());
-    }
   }
 
   private List<String> getParameterNames(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/index/RefState.java b/java/com/google/gerrit/index/RefState.java
index 956dcab..ed38de9 100644
--- a/java/com/google/gerrit/index/RefState.java
+++ b/java/com/google/gerrit/index/RefState.java
@@ -20,8 +20,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
+import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.git.ObjectIds;
@@ -33,10 +32,10 @@
 
 @AutoValue
 public abstract class RefState {
-  public static SetMultimap<Project.NameKey, RefState> parseStates(Iterable<byte[]> states) {
+  public static ImmutableSetMultimap<Project.NameKey, RefState> parseStates(
+      Iterable<byte[]> states) {
     RefState.check(states != null, null);
-    SetMultimap<Project.NameKey, RefState> result =
-        MultimapBuilder.hashKeys().hashSetValues().build();
+    ImmutableSetMultimap.Builder<Project.NameKey, RefState> result = ImmutableSetMultimap.builder();
     for (byte[] b : states) {
       RefState.check(b != null, null);
       String s = new String(b, UTF_8);
@@ -44,7 +43,7 @@
       RefState.check(parts.size() == 3 && !parts.get(0).isEmpty() && !parts.get(1).isEmpty(), s);
       result.put(Project.nameKey(parts.get(0)), RefState.create(parts.get(1), parts.get(2)));
     }
-    return result;
+    return result.build();
   }
 
   public static RefState create(String ref, String sha) {
diff --git a/java/com/google/gerrit/index/query/TimestampRangePredicate.java b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
index 38b2b73..42f8aa8 100644
--- a/java/com/google/gerrit/index/query/TimestampRangePredicate.java
+++ b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
@@ -34,6 +34,10 @@
     super(def, name, value);
   }
 
+  protected Timestamp getValueTimestamp(I object) {
+    return (Timestamp) this.getField().get(object);
+  }
+
   public abstract Date getMinTimestamp();
 
   public abstract Date getMaxTimestamp();
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index 360331a..475dac4 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.gerrit.lucene.LuceneChangeIndex.ID2_SORT_FIELD;
 import static com.google.gerrit.lucene.LuceneChangeIndex.ID_SORT_FIELD;
+import static com.google.gerrit.lucene.LuceneChangeIndex.MERGED_ON_SORT_FIELD;
 import static com.google.gerrit.lucene.LuceneChangeIndex.UPDATED_SORT_FIELD;
 import static com.google.gerrit.server.index.change.ChangeSchemaDefinitions.NAME;
 
@@ -128,6 +129,9 @@
     } else if (f == ChangeField.UPDATED) {
       long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
       doc.add(new NumericDocValuesField(UPDATED_SORT_FIELD, t));
+    } else if (f == ChangeField.MERGED_ON) {
+      long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
+      doc.add(new NumericDocValuesField(MERGED_ON_SORT_FIELD, t));
     }
     super.add(doc, values);
   }
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index c162c77..b1141d9 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
@@ -73,6 +74,7 @@
 import com.google.protobuf.MessageLite;
 import java.io.IOException;
 import java.nio.file.Path;
+import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Iterator;
@@ -111,6 +113,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
+  static final String MERGED_ON_SORT_FIELD = sortFieldName(ChangeField.MERGED_ON);
   static final String ID_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID);
   static final String ID2_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID_STR);
 
@@ -141,6 +144,7 @@
   private static final String UNRESOLVED_COMMENT_COUNT_FIELD =
       ChangeField.UNRESOLVED_COMMENT_COUNT.getName();
   private static final String ATTENTION_SET_FULL_FIELD = ChangeField.ATTENTION_SET_FULL.getName();
+  private static final String MERGED_ON_FIELD = ChangeField.MERGED_ON.getName();
 
   @FunctionalInterface
   static interface IdTerm {
@@ -342,6 +346,7 @@
   private Sort getSort() {
     return new Sort(
         new SortField(UPDATED_SORT_FIELD, SortField.Type.LONG, true),
+        new SortField(MERGED_ON_SORT_FIELD, SortField.Type.LONG, true),
         new SortField(idSortFieldName, SortField.Type.LONG, true));
   }
 
@@ -585,6 +590,9 @@
     if (fields.contains(REF_STATE_PATTERN_FIELD)) {
       decodeRefStatePatterns(doc, cd);
     }
+    if (fields.contains(MERGED_ON_FIELD)) {
+      decodeMergedOn(doc, cd);
+    }
 
     decodeUnresolvedCommentCount(doc, cd);
     decodeTotalCommentCount(doc, cd);
@@ -717,7 +725,7 @@
   }
 
   private void decodeRefStates(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setRefStates(copyAsBytes(doc.get(REF_STATE_FIELD)));
+    cd.setRefStates(RefState.parseStates(copyAsBytes(doc.get(REF_STATE_FIELD))));
   }
 
   private void decodeRefStatePatterns(ListMultimap<String, IndexableField> doc, ChangeData cd) {
@@ -741,6 +749,16 @@
     }
   }
 
+  private void decodeMergedOn(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    IndexableField mergedOnField =
+        Iterables.getFirst(doc.get(MERGED_ON_FIELD), /* defaultValue= */ null);
+    Timestamp mergedOn = null;
+    if (mergedOnField != null && mergedOnField.numericValue() != null) {
+      mergedOn = new Timestamp(mergedOnField.numericValue().longValue());
+    }
+    cd.setMergedOn(mergedOn);
+  }
+
   private static <T> List<T> decodeProtos(
       ListMultimap<String, IndexableField> doc, String fieldName, ProtoConverter<?, T> converter) {
     return doc.get(fieldName).stream()
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index 9f8b62e..f7a2248 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -19,6 +19,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
@@ -30,7 +31,6 @@
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.options.AutoFlush;
diff --git a/java/com/google/gerrit/metrics/proc/ProcMetricModule.java b/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
index 20ac8fa..09e40c1 100644
--- a/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
+++ b/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
@@ -27,6 +27,7 @@
 import java.lang.management.GarbageCollectorMXBean;
 import java.lang.management.ManagementFactory;
 import java.lang.management.MemoryMXBean;
+import java.lang.management.MemoryPoolMXBean;
 import java.lang.management.MemoryUsage;
 import java.lang.management.OperatingSystemMXBean;
 import java.lang.management.ThreadMXBean;
@@ -41,6 +42,7 @@
     procCpuLoad(metrics);
     procJvmGc(metrics);
     procJvmMemory(metrics);
+    procJvmMemoryPool(metrics);
     procJvmThread(metrics);
   }
 
@@ -167,6 +169,50 @@
         });
   }
 
+  private void procJvmMemoryPool(MetricMaker metrics) {
+    Field<String> poolName =
+        Field.ofString("pool_name", Metadata.Builder::memoryPoolName)
+            .description("The name of the memory pool")
+            .build();
+
+    CallbackMetric1<String, Long> committed =
+        metrics.newCallbackMetric(
+            "proc/jvm/memory/pool/committed",
+            Long.class,
+            new Description("Committed pool size").setUnit(Units.BYTES),
+            poolName);
+
+    CallbackMetric1<String, Long> max =
+        metrics.newCallbackMetric(
+            "proc/jvm/memory/pool/max",
+            Long.class,
+            new Description("Max pool size").setUnit(Units.BYTES),
+            poolName);
+
+    CallbackMetric1<String, Long> used =
+        metrics.newCallbackMetric(
+            "proc/jvm/memory/pool/used",
+            Long.class,
+            new Description("Used pool size").setUnit(Units.BYTES),
+            poolName);
+
+    metrics.newTrigger(
+        committed,
+        max,
+        used,
+        () -> {
+          for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) {
+            if (!pool.isValid()) {
+              continue;
+            }
+            MemoryUsage u = pool.getUsage();
+            committed.set(pool.getName(), u.getCommitted());
+            max.set(pool.getName(), u.getMax());
+            used.set(pool.getName(), u.getUsed());
+          }
+        });
+  }
+
   private void procJvmGc(MetricMaker metrics) {
     Field<String> gcNameField =
         Field.ofString("gc_name", Metadata.Builder::garbageCollectorName)
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanFactory.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanFactory.java
new file mode 100644
index 0000000..1e0c4f0
--- /dev/null
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanFactory.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.metrics.proc;
+
+import java.lang.management.ManagementFactory;
+import java.lang.management.ThreadMXBean;
+
+public class ThreadMXBeanFactory {
+
+  private ThreadMXBeanFactory() {}
+
+  public static ThreadMXBeanInterface create() {
+    ThreadMXBean sys = ManagementFactory.getThreadMXBean();
+    if (sys instanceof com.sun.management.ThreadMXBean) {
+      return new ThreadMXBeanSun(sys);
+    }
+    return new ThreadMXBeanJava(sys);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanInterface.java
similarity index 62%
rename from java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
rename to java/com/google/gerrit/metrics/proc/ThreadMXBeanInterface.java
index 08d6ce7..546924f 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanInterface.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2019 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -11,13 +11,12 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
+package com.google.gerrit.metrics.proc;
 
-package com.google.gerrit.server.change;
+public interface ThreadMXBeanInterface {
+  long getCurrentThreadCpuTime();
 
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.util.List;
+  long getCurrentThreadUserTime();
 
-public interface PluginDefinedAttributesFactory {
-  List<PluginDefinedInfo> create(ChangeData cd);
+  long getCurrentThreadAllocatedBytes();
 }
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanJava.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanJava.java
new file mode 100644
index 0000000..29dd42a
--- /dev/null
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanJava.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.metrics.proc;
+
+import java.lang.management.ThreadMXBean;
+
+class ThreadMXBeanJava implements ThreadMXBeanInterface {
+  private final ThreadMXBean sys;
+
+  ThreadMXBeanJava(ThreadMXBean sys) {
+    this.sys = sys;
+  }
+
+  @Override
+  public long getCurrentThreadCpuTime() {
+    return sys.getCurrentThreadCpuTime();
+  }
+
+  @Override
+  public long getCurrentThreadUserTime() {
+    return sys.getCurrentThreadUserTime();
+  }
+
+  @Override
+  public long getCurrentThreadAllocatedBytes() {
+    return -1;
+  }
+}
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
new file mode 100644
index 0000000..9e43a05
--- /dev/null
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
@@ -0,0 +1,42 @@
+// 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.metrics.proc;
+
+import com.sun.management.ThreadMXBean;
+
+class ThreadMXBeanSun implements ThreadMXBeanInterface {
+  private final ThreadMXBean sys;
+
+  ThreadMXBeanSun(java.lang.management.ThreadMXBean sys) {
+    this.sys = (ThreadMXBean) sys;
+  }
+
+  @Override
+  public long getCurrentThreadCpuTime() {
+    return sys.getCurrentThreadCpuTime();
+  }
+
+  @Override
+  public long getCurrentThreadUserTime() {
+    return sys.getCurrentThreadUserTime();
+  }
+
+  @Override
+  public long getCurrentThreadAllocatedBytes() {
+    // TODO(ms): call getCurrentThreadAllocatedBytes as soon as this is available in the patched
+    // Java version used by bazel
+    return sys.getThreadAllocatedBytes(Thread.currentThread().getId());
+  }
+}
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index 2b1b83d..387ff2d 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -7,6 +7,7 @@
     resources = ["//resources/com/google/gerrit/pgm"],
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/elasticsearch",
@@ -17,6 +18,7 @@
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/httpd/auth/oauth",
         "//java/com/google/gerrit/httpd/auth/openid",
+        "//java/com/google/gerrit/httpd/auth/restapi",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/launcher",
@@ -42,6 +44,7 @@
         "//lib:args4j",
         "//lib:guava",
         "//lib:jgit",
+        "//lib:jgit-ssh-apache",
         "//lib:protobuf",
         "//lib:servlet-api-without-neverlink",
         "//lib/auto:auto-value",
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 5f675bcc..266eb6f 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -21,6 +21,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.auth.AuthModule;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.client.AuthType;
@@ -40,6 +41,7 @@
 import com.google.gerrit.httpd.WebSshGlueModule;
 import com.google.gerrit.httpd.auth.oauth.OAuthModule;
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
+import com.google.gerrit.httpd.auth.restapi.OAuthRestModule;
 import com.google.gerrit.httpd.plugins.HttpPluginModule;
 import com.google.gerrit.httpd.raw.StaticModule;
 import com.google.gerrit.index.IndexType;
@@ -113,6 +115,7 @@
 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.IndexCommandsModule;
 import com.google.gerrit.sshd.commands.SequenceCommandsModule;
@@ -457,6 +460,7 @@
     if (VersionManager.getOnlineUpgrade(config)) {
       modules.add(new OnlineUpgrader.Module());
     }
+    modules.add(new OAuthRestModule());
     modules.add(new RestApiModule());
     modules.add(new GpgModule(config));
     modules.add(new StartupChecks.Module());
@@ -480,6 +484,7 @@
           });
     }
     modules.add(new DefaultUrlFormatter.Module());
+    SshSessionFactoryInitializer.init(config);
     if (sshd) {
       modules.add(SshKeyCacheImpl.module());
     } else {
@@ -511,6 +516,9 @@
     List<Module> libModules = LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE);
     libModules.addAll(testSysModules);
 
+    AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
+    modules.add(new AuthModule(authConfig));
+
     return cfgInjector.createChildInjector(ModuleOverloader.override(modules, libModules));
   }
 
@@ -595,6 +603,7 @@
     } else if (authConfig.getAuthType() == AuthType.OAUTH) {
       modules.add(new OAuthModule());
     }
+
     modules.add(sysInjector.getInstance(GetUserFilter.Module.class));
 
     // StaticModule contains a "/*" wildcard, place it last.
diff --git a/java/com/google/gerrit/pgm/Init.java b/java/com/google/gerrit/pgm/Init.java
index 19d19d4..4c7b47b 100644
--- a/java/com/google/gerrit/pgm/Init.java
+++ b/java/com/google/gerrit/pgm/Init.java
@@ -92,6 +92,9 @@
   @Option(name = "--skip-download", usage = "Don't download given library")
   private List<String> skippedDownloads;
 
+  @Option(name = "--reindex-threads", usage = "Number of threads to use for reindex after init")
+  private int reindexThreads = 1;
+
   @Inject Browser browser;
 
   private GerritIndexStatus indexStatus;
@@ -283,7 +286,7 @@
     }
     List<String> reindexArgs =
         Lists.newArrayList(
-            "--site-path", getSitePath().toString(), "--threads", Integer.toString(1));
+            "--site-path", getSitePath().toString(), "--threads", Integer.toString(reindexThreads));
     for (String index : indices) {
       reindexArgs.add("--index");
       reindexArgs.add(index);
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLog.java b/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
index 27a53c2..e88bb88 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.httpd.GetUserFilter;
+import com.google.gerrit.httpd.RequestMetricsFilter;
 import com.google.gerrit.httpd.restapi.LogRedactUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.util.SystemLog;
@@ -53,6 +54,9 @@
   protected static final String P_LATENCY = "Latency";
   protected static final String P_REFERER = "Referer";
   protected static final String P_USER_AGENT = "User-Agent";
+  protected static final String P_CPU_TOTAL = "Cpu-Total";
+  protected static final String P_CPU_USER = "Cpu-User";
+  protected static final String P_MEMORY = "Memory";
   protected static final String P_COMMAND_STATUS = "Command-Status";
 
   private final AsyncAppender async;
@@ -118,6 +122,14 @@
     set(event, P_USER_AGENT, req.getHeader("User-Agent"));
     set(event, P_COMMAND_STATUS, rsp.getHeader(GIT_COMMAND_STATUS_HEADER));
 
+    RequestMetricsFilter.Context ctx =
+        (RequestMetricsFilter.Context) req.getAttribute(RequestMetricsFilter.METRICS_CONTEXT);
+    if (ctx != null) {
+      set(event, P_CPU_TOTAL, ctx.getTotalCpuTime());
+      set(event, P_CPU_USER, ctx.getUserCpuTime());
+      set(event, P_MEMORY, ctx.getAllocatedMemory());
+    }
+
     async.append(event);
   }
 
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java b/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
index 807b311..54c587b 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
@@ -16,8 +16,11 @@
 
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_COMMAND_STATUS;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_CONTENT_LENGTH;
+import static com.google.gerrit.pgm.http.jetty.HttpLog.P_CPU_TOTAL;
+import static com.google.gerrit.pgm.http.jetty.HttpLog.P_CPU_USER;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_HOST;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_LATENCY;
+import static com.google.gerrit.pgm.http.jetty.HttpLog.P_MEMORY;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_METHOD;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_PROTOCOL;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_REFERER;
@@ -49,6 +52,9 @@
     public String status;
     public String contentLength;
     public String latency;
+    public String cpuTotal;
+    public String cpuUser;
+    public String memory;
     public String referer;
     public String userAgent;
     public String commandStatus;
@@ -64,6 +70,9 @@
       this.status = getMdcString(event, P_STATUS);
       this.contentLength = getMdcString(event, P_CONTENT_LENGTH);
       this.latency = getMdcString(event, P_LATENCY);
+      this.cpuTotal = getMdcString(event, P_CPU_TOTAL);
+      this.cpuUser = getMdcString(event, P_CPU_USER);
+      this.memory = getMdcString(event, P_MEMORY);
       this.referer = getMdcString(event, P_REFERER);
       this.userAgent = getMdcString(event, P_USER_AGENT);
       this.commandStatus = getMdcString(event, P_COMMAND_STATUS);
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java b/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
index b83fcc5..ddc1b5e 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
@@ -72,6 +72,15 @@
     dq_opt(buf, event, HttpLog.P_USER_AGENT);
 
     buf.append(' ');
+    opt(buf, event, HttpLog.P_CPU_TOTAL);
+
+    buf.append(' ');
+    opt(buf, event, HttpLog.P_CPU_USER);
+
+    buf.append(' ');
+    opt(buf, event, HttpLog.P_MEMORY);
+
+    buf.append(' ');
     dq_opt(buf, event, HttpLog.P_COMMAND_STATUS);
 
     buf.append('\n');
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index ca28255..95572b6 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.InitFlags;
@@ -31,7 +32,6 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.AuditLogFormatter;
 import com.google.gerrit.server.group.db.GroupConfig;
 import com.google.gerrit.server.group.db.GroupNameNotes;
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index effb4c6..2e32066 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
@@ -28,7 +29,6 @@
 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.group.InternalGroup;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.group.GroupIndex;
@@ -88,6 +88,9 @@
 
   @Override
   public void postRun() throws Exception {
+    if (!accounts.hasAnyAccount()) {
+      welcome();
+    }
     AuthType authType = flags.cfg.getEnum(AuthType.values(), "auth", null, "type", null);
     if (authType != AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT) {
       return;
@@ -146,6 +149,15 @@
     }
   }
 
+  private void welcome() {
+    ui.message(
+        "============================================================================\n"
+            + "Welcome to the Gerrit community\n\n"
+            + "Find more information on the homepage: https://www.gerritcodereview.com\n"
+            + "Discuss Gerrit on the mailing list: https://groups.google.com/g/repo-discuss\n"
+            + "============================================================================\n");
+  }
+
   private String readEmail(AccountSshKey sshKey) {
     String defaultEmail = "admin@example.com";
     if (sshKey != null && sshKey.comment() != null) {
diff --git a/java/com/google/gerrit/pgm/init/InitJGitConfig.java b/java/com/google/gerrit/pgm/init/InitJGitConfig.java
index 6e37f7f..bad55b4 100644
--- a/java/com/google/gerrit/pgm/init/InitJGitConfig.java
+++ b/java/com/google/gerrit/pgm/init/InitJGitConfig.java
@@ -64,22 +64,9 @@
                 + "gc should be configured in gc config section or run as a separate process.");
       }
 
-      if (!jgitConfig
+      if (jgitConfig
           .getNames(ConfigConstants.CONFIG_PROTOCOL_SECTION)
           .contains(ConfigConstants.CONFIG_KEY_VERSION)) {
-        jgitConfig.setString(
-            ConfigConstants.CONFIG_PROTOCOL_SECTION,
-            null,
-            ConfigConstants.CONFIG_KEY_VERSION,
-            TransferConfig.ProtocolVersion.V2.version());
-        jgitConfig.save();
-        ui.error(
-            String.format(
-                "Auto-configured \"%s.%s = %s\" to activate git wire protocol version 2.",
-                ConfigConstants.CONFIG_PROTOCOL_SECTION,
-                ConfigConstants.CONFIG_KEY_VERSION,
-                TransferConfig.ProtocolVersion.V2.version()));
-      } else {
         String version =
             jgitConfig.getString(
                 ConfigConstants.CONFIG_PROTOCOL_SECTION, null, ConfigConstants.CONFIG_KEY_VERSION);
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index af01343..71aec5b 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -43,7 +43,6 @@
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
@@ -54,8 +53,8 @@
 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.EnableReverseDnsLookup;
-import com.google.gerrit.server.config.EnableReverseDnsLookupProvider;
+import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
+import com.google.gerrit.server.config.EnablePeerIPInReflogRecordProvider;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
 import com.google.gerrit.server.config.SysExecutorModule;
@@ -131,16 +130,14 @@
     bind(new TypeLiteral<List<CommentLinkInfo>>() {})
         .toProvider(CommentLinkProvider.class)
         .in(SINGLETON);
-    bind(new TypeLiteral<DynamicSet<ChangeAttributeFactory>>() {})
-        .toInstance(DynamicSet.emptySet());
     bind(new TypeLiteral<DynamicMap<RestView<CommitResource>>>() {})
         .toInstance(DynamicMap.emptyMap());
     bind(String.class)
         .annotatedWith(CanonicalWebUrl.class)
         .toProvider(CanonicalWebUrlProvider.class);
     bind(Boolean.class)
-        .annotatedWith(EnableReverseDnsLookup.class)
-        .toProvider(EnableReverseDnsLookupProvider.class)
+        .annotatedWith(EnablePeerIPInReflogRecord.class)
+        .toProvider(EnablePeerIPInReflogRecordProvider.class)
         .in(SINGLETON);
     bind(Realm.class).to(FakeRealm.class);
     bind(IdentifiedUser.class).toProvider(Providers.of(null));
diff --git a/java/com/google/gerrit/pgm/util/ErrorLogFile.java b/java/com/google/gerrit/pgm/util/ErrorLogFile.java
index 634e56b..3d8ac8d 100644
--- a/java/com/google/gerrit/pgm/util/ErrorLogFile.java
+++ b/java/com/google/gerrit/pgm/util/ErrorLogFile.java
@@ -95,14 +95,9 @@
     }
 
     if (json) {
-      Boolean enableReverseDnsLookup =
-          config.getBoolean("gerrit", null, "enableReverseDnsLookup", false);
       root.addAppender(
           SystemLog.createAppender(
-              logdir,
-              LOG_NAME + JSON_SUFFIX,
-              new ErrorLogJsonLayout(enableReverseDnsLookup),
-              rotate));
+              logdir, LOG_NAME + JSON_SUFFIX, new ErrorLogJsonLayout(), rotate));
     }
   }
 }
diff --git a/java/com/google/gerrit/pgm/util/ErrorLogJsonLayout.java b/java/com/google/gerrit/pgm/util/ErrorLogJsonLayout.java
index 85378a4..7d4abfc 100644
--- a/java/com/google/gerrit/pgm/util/ErrorLogJsonLayout.java
+++ b/java/com/google/gerrit/pgm/util/ErrorLogJsonLayout.java
@@ -26,13 +26,6 @@
 
 /** Layout for formatting error log events in the JSON format. */
 public class ErrorLogJsonLayout extends JsonLayout {
-  private final Boolean enableReverseDnsLookup;
-
-  public ErrorLogJsonLayout(Boolean enableDnsReverseLookup) {
-    super();
-    this.enableReverseDnsLookup = enableDnsReverseLookup;
-  }
-
   @Override
   public JsonLogEntry toJsonLogEntry(LoggingEvent event) {
     return new ErrorJsonLogEntry(event);
@@ -86,7 +79,7 @@
 
     public ErrorJsonLogEntry(LoggingEvent event) {
       this.timestamp = timestampFormatter.format(event.getTimeStamp());
-      this.sourceHost = getSourceHost(enableReverseDnsLookup);
+      this.sourceHost = getSourceHost();
       this.message = event.getRenderedMessage();
       this.file = event.getLocationInformation().getFileName();
       this.lineNumber = event.getLocationInformation().getLineNumber();
@@ -102,14 +95,9 @@
       }
     }
 
-    private String getSourceHost(Boolean enableReverseDnsLookup) {
-      InetAddress in;
+    private String getSourceHost() {
       try {
-        in = InetAddress.getLocalHost();
-        if (Boolean.TRUE.equals(enableReverseDnsLookup)) {
-          return in.getCanonicalHostName();
-        }
-        return in.getHostAddress();
+        return InetAddress.getLocalHost().getHostAddress();
       } catch (UnknownHostException e) {
         return "unknown-host";
       }
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index 98558fb..c3be0a4 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.config.GerritRuntime;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.experiments.ConfigExperimentFeatures;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
 import com.google.gerrit.server.git.SystemReaderInstaller;
 import com.google.gerrit.server.schema.SchemaModule;
@@ -128,6 +129,9 @@
 
     modules.add(new SchemaModule());
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
+    // The only implementation of experiments is available in all programs that can use
+    // gerrit.config
+    modules.add(new ConfigExperimentFeatures.Module());
 
     try {
       return Guice.createInjector(
diff --git a/java/com/google/gerrit/server/ApprovalInference.java b/java/com/google/gerrit/server/ApprovalInference.java
index aa3ef89..04d874c 100644
--- a/java/com/google/gerrit/server/ApprovalInference.java
+++ b/java/com/google/gerrit/server/ApprovalInference.java
@@ -25,23 +25,36 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
 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.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 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 org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
@@ -59,13 +72,21 @@
   private final ProjectCache projectCache;
   private final ChangeKindCache changeKindCache;
   private final LabelNormalizer labelNormalizer;
+  private final PatchListCache patchListCache;
+  private final GitRepositoryManager repositoryManager;
 
   @Inject
   ApprovalInference(
-      ProjectCache projectCache, ChangeKindCache changeKindCache, LabelNormalizer labelNormalizer) {
+      ProjectCache projectCache,
+      ChangeKindCache changeKindCache,
+      LabelNormalizer labelNormalizer,
+      PatchListCache patchListCache,
+      GitRepositoryManager repositoryManager) {
     this.projectCache = projectCache;
     this.changeKindCache = changeKindCache;
     this.labelNormalizer = labelNormalizer;
+    this.patchListCache = patchListCache;
+    this.repositoryManager = repositoryManager;
   }
 
   /**
@@ -93,10 +114,16 @@
   }
 
   private static boolean canCopy(
-      ProjectState project, PatchSetApproval psa, PatchSet.Id psId, ChangeKind kind) {
+      ProjectState project,
+      PatchSetApproval psa,
+      PatchSet.Id psId,
+      ChangeKind kind,
+      LabelType type,
+      @Nullable PatchList patchListCurrentPatchset,
+      @Nullable PatchList patchListPriorPatchset) {
     int n = psa.key().patchSetId().get();
     checkArgument(n != psId.get());
-    LabelType type = project.getLabelTypes().byLabel(psa.labelId());
+
     if (type == null) {
       logger.atFine().log(
           "approval %d on label %s of patch set %d of change %d cannot be copied"
@@ -153,6 +180,21 @@
           psa.value(),
           project.getName());
       return true;
+    } else if (type.isCopyAllScoresIfListOfFilesDidNotChange()
+        && didListOfFilesNotChange(patchListCurrentPatchset, patchListPriorPatchset)) {
+      logger.atFine().log(
+          "approval %d on label %s of patch set %d of change %d can be copied"
+              + " to patch set %d because the label has set "
+              + "copyAllScoresIfListOfFilesDidNotChange = true on "
+              + "project %s and list of files did not change (maybe except a rename, which is "
+              + "still the same file).",
+          psa.value(),
+          psa.label(),
+          n,
+          psa.key().patchSetId().changeId().get(),
+          psId.get(),
+          project.getName());
+      return true;
     }
     switch (kind) {
       case MERGE_FIRST_PARENT_UPDATE:
@@ -271,6 +313,20 @@
     }
   }
 
+  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 Collection<PatchSetApproval> getForPatchSetWithoutNormalization(
       ChangeNotes notes,
       ProjectState project,
@@ -331,15 +387,60 @@
     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;
+    LabelTypes labelTypes = project.getLabelTypes();
     for (PatchSetApproval psa : priorApprovals) {
       if (resultByUser.contains(psa.label(), psa.accountId())) {
         continue;
       }
-      if (!canCopy(project, psa, ps.id(), kind)) {
+      LabelType type = labelTypes.byLabel(psa.labelId());
+      // Only compute patchList if there is a relevant label, since this is expensive.
+      if (patchListCurrentPatchset == null
+          && type != null
+          && type.isCopyAllScoresIfListOfFilesDidNotChange()) {
+        patchListCurrentPatchset = getPatchList(project, ps);
+        patchListPriorPatchset = getPatchList(project, priorPatchSet.getValue());
+      }
+      if (!canCopy(
+          project, psa, ps.id(), kind, type, patchListCurrentPatchset, patchListPriorPatchset)) {
         continue;
       }
       resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(ps.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.
+   */
+  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()));
+    try {
+      return patchListCache.get(key, project.getNameKey());
+    } catch (PatchListNotAvailableException ex) {
+      throw new StorageException(
+          "failed to compute difference in files, so won't copy"
+              + " votes on labels even if list of files is the same and "
+              + "copyAllIfListOfFilesDidNotChange",
+          ex);
+    }
+  }
+
+  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);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 069006b..404906d 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -62,7 +62,6 @@
         "//java/com/google/gerrit/server/util/git",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/util/cli",
-        "//java/com/google/gerrit/util/ssl",
         "//java/org/apache/commons/net",
         "//lib:args4j",
         "//lib:autolink",
@@ -98,7 +97,6 @@
         "//lib:guava-retrying",
         "//lib:jgit",
         "//lib:jgit-archive",
-        "//lib:jsch",
         "//lib:juniversalchardet",
         "//lib:mime-util",
         "//lib:protobuf",
@@ -113,6 +111,7 @@
         "//lib/commons:compress",
         "//lib/commons:dbcp",
         "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/commons:net",
         "//lib/commons:validator",
         "//lib/errorprone:annotations",
diff --git a/java/com/google/gerrit/server/CommentContextLoader.java b/java/com/google/gerrit/server/CommentContextLoader.java
deleted file mode 100644
index bbc7cf3..0000000
--- a/java/com/google/gerrit/server/CommentContextLoader.java
+++ /dev/null
@@ -1,165 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import static java.util.stream.Collectors.groupingBy;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.ContextLineInfo;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.Text;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.treewalk.TreeWalk;
-
-/**
- * Computes the list of {@link ContextLineInfo} for a given comment, that is, the lines of the
- * source file surrounding and including the area where the comment was written.
- */
-public class CommentContextLoader {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final GitRepositoryManager repoManager;
-  private final Project.NameKey project;
-  private Map<ContextData, List<ContextLineInfo>> candidates;
-
-  public interface Factory {
-    CommentContextLoader create(Project.NameKey project);
-  }
-
-  @Inject
-  CommentContextLoader(GitRepositoryManager repoManager, @Assisted Project.NameKey project) {
-    this.repoManager = repoManager;
-    this.project = project;
-    this.candidates = new HashMap<>();
-  }
-
-  /**
-   * Returns an empty list of {@link ContextLineInfo}. Clients are expected to call this method one
-   * or more times. Each call returns a reference to an empty {@link List
-   * List&lt;ContextLineInfo&gt;}.
-   *
-   * <p>A single call to {@link #fill()} will cause all list references returned from this method to
-   * be populated. If a client calls this method again with a comment that was passed before calling
-   * {@link #fill()}, the new populated list will be returned.
-   *
-   * @param comment the comment entity for which we want to load the context
-   * @return a list of {@link ContextLineInfo}
-   */
-  public List<ContextLineInfo> getContext(CommentInfo comment) {
-    ContextData key =
-        ContextData.create(
-            comment.id,
-            ObjectId.fromString(comment.commitId),
-            comment.path,
-            getStartAndEndLines(comment));
-    List<ContextLineInfo> context = candidates.get(key);
-    if (context == null) {
-      context = new ArrayList<>();
-      candidates.put(key, context);
-    }
-    return context;
-  }
-
-  /**
-   * A call to this method loads the context for all comments stored in {@link
-   * CommentContextLoader#candidates}. This is useful so that the repository is opened once for all
-   * comments.
-   */
-  public void fill() {
-    // Group comments by commit ID so that each commit is parsed only once
-    Map<ObjectId, List<ContextData>> commentsByCommitId =
-        candidates.keySet().stream().collect(groupingBy(ContextData::commitId));
-
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      for (ObjectId commitId : commentsByCommitId.keySet()) {
-        RevCommit commit = rw.parseCommit(commitId);
-        for (ContextData k : commentsByCommitId.get(commitId)) {
-          if (!k.range().isPresent()) {
-            continue;
-          }
-          try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), k.path(), commit.getTree())) {
-            if (tw == null) {
-              logger.atWarning().log(
-                  "Failed to find path %s in the git tree of ID %s.",
-                  k.path(), commit.getTree().getId());
-              continue;
-            }
-            ObjectId id = tw.getObjectId(0);
-            Text src = new Text(repo.open(id, Constants.OBJ_BLOB));
-            List<ContextLineInfo> contextLines = candidates.get(k);
-            Range r = k.range().get();
-            for (int i = r.start(); i <= r.end(); i++) {
-              contextLines.add(new ContextLineInfo(i, src.getString(i - 1)));
-            }
-          }
-        }
-      }
-    } catch (IOException e) {
-      throw new StorageException("Failed to load the comment context", e);
-    }
-  }
-
-  private static Optional<Range> getStartAndEndLines(CommentInfo comment) {
-    if (comment.range != null) {
-      return Optional.of(Range.create(comment.range.startLine, comment.range.endLine));
-    } else if (comment.line != null) {
-      return Optional.of(Range.create(comment.line, comment.line));
-    }
-    return Optional.empty();
-  }
-
-  @AutoValue
-  abstract static class Range {
-    static Range create(int start, int end) {
-      return new AutoValue_CommentContextLoader_Range(start, end);
-    }
-
-    abstract int start();
-
-    abstract int end();
-  }
-
-  @AutoValue
-  abstract static class ContextData {
-    static ContextData create(String id, ObjectId commitId, String path, Optional<Range> range) {
-      return new AutoValue_CommentContextLoader_ContextData(id, commitId, path, range);
-    }
-
-    abstract String id();
-
-    abstract ObjectId commitId();
-
-    abstract String path();
-
-    abstract Optional<Range> range();
-  }
-}
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index 75afc04..7012944 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server;
 
-import com.google.gerrit.common.Nullable;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -31,17 +31,19 @@
  * @see IdentifiedUser
  */
 public abstract class CurrentUser {
-  /** Unique key for plugin/extension specific data on a CurrentUser. */
-  public static final class PropertyKey<T> {
-    public static <T> PropertyKey<T> create() {
-      return new PropertyKey<>();
-    }
+  public static final PropertyMap.Key<ExternalId.Key> LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY =
+      PropertyMap.key();
 
-    private PropertyKey() {}
+  private final PropertyMap properties;
+  private AccessPath accessPath = AccessPath.UNKNOWN;
+
+  protected CurrentUser() {
+    this.properties = PropertyMap.EMPTY;
   }
 
-  private AccessPath accessPath = AccessPath.UNKNOWN;
-  private PropertyKey<ExternalId.Key> lastLoginExternalIdPropertyKey = PropertyKey.create();
+  protected CurrentUser(PropertyMap properties) {
+    this.properties = properties;
+  }
 
   /** How this user is accessing the Gerrit Code Review application. */
   public final AccessPath getAccessPath() {
@@ -127,35 +129,41 @@
         getClass().getSimpleName() + " is not an IdentifiedUser");
   }
 
+  /**
+   * Returns all email addresses associated with this user. For {@link AnonymousUser} and other
+   * users that don't represent a person user or service account, this set will be empty.
+   */
+  public ImmutableSet<String> getEmailAddresses() {
+    return ImmutableSet.of();
+  }
+
+  /**
+   * Returns all {@link com.google.gerrit.server.account.externalids.ExternalId.Key}s associated
+   * with this user. For {@link AnonymousUser} and other users that don't represent a person user or
+   * service account, this set will be empty.
+   */
+  public ImmutableSet<ExternalId.Key> getExternalIdKeys() {
+    return ImmutableSet.of();
+  }
+
   /** Check if the CurrentUser is an InternalUser. */
   public boolean isInternalUser() {
     return false;
   }
 
   /**
-   * Lookup a previously stored property.
+   * Lookup a stored property.
    *
-   * @param key unique property key.
-   * @return previously stored value, or {@code Optional#empty()}.
+   * @param key unique property key. This key has to be the same instance that was used to store the
+   *     value when constructing the {@link PropertyMap}
+   * @return stored value, or {@code Optional#empty()}.
    */
-  public <T> Optional<T> get(PropertyKey<T> key) {
-    return Optional.empty();
-  }
-
-  /**
-   * Store a property for later retrieval.
-   *
-   * @param key unique property key.
-   * @param value value to store; or {@code null} to clear the value.
-   */
-  public <T> void put(PropertyKey<T> key, @Nullable T value) {}
-
-  public void setLastLoginExternalIdKey(ExternalId.Key externalIdKey) {
-    put(lastLoginExternalIdPropertyKey, externalIdKey);
+  public <T> Optional<T> get(PropertyMap.Key<T> key) {
+    return properties.get(key);
   }
 
   public Optional<ExternalId.Key> getLastLoginExternalIdKey() {
-    return get(lastLoginExternalIdPropertyKey);
+    return get(LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/DynamicOptions.java b/java/com/google/gerrit/server/DynamicOptions.java
index ebf4ec6..7f9fbd2 100644
--- a/java/com/google/gerrit/server/DynamicOptions.java
+++ b/java/com/google/gerrit/server/DynamicOptions.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.server.plugins.DelegatingClassLoader;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.inject.Injector;
@@ -29,7 +30,7 @@
 import java.util.WeakHashMap;
 
 /** Helper class to define and parse options from plugins on ssh and RestAPI commands. */
-public class DynamicOptions {
+public class DynamicOptions implements AutoCloseable {
   /**
    * To provide additional options, bind a DynamicBean. For example:
    *
@@ -98,7 +99,9 @@
    *
    * <p>Do this by binding to the name of the command you are going to bind to and providing an
    * Iterable of Module names to instantiate and add to the Injector used to instantiate the
-   * DynamicBean in the other classLoader. For example:
+   * DynamicBean in the other classLoader. This interface supports running LifecycleListeners which
+   * are defined by the Modules being provided. The duration of the lifecycle starts when a ssh or
+   * http request starts and ends when the request completes. For example:
    *
    * <pre>
    *   bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
@@ -106,7 +109,7 @@
    *           "com.google.gerrit.plugins.otherplugin.command"))
    *       .to(MyOptionsModulesClassNamesProvider.class);
    *
-   *   static class MyOptionsModulesClassNamesProvider implements DynamicOptions.ClassNameProvider {
+   *   static class MyOptionsModulesClassNamesProvider implements DynamicOptions.ModulesClassNamesProvider {
    *     {@literal @}Override
    *     public String getClassName() {
    *       return "com.googlesource.gerrit.plugins.myplugin.CommandOptions";
@@ -190,13 +193,17 @@
   protected Object bean;
   protected Map<String, DynamicBean> beansByPlugin;
   protected Injector injector;
+  protected DynamicMap<DynamicBean> dynamicBeans;
+  protected LifecycleManager lifecycleManager;
 
   /**
    * Internal: For Gerrit to include options from DynamicBeans, setup a DynamicMap and instantiate
    * this class so the following methods can be called if desired:
    *
    * <pre>
-   *    DynamicOptions pluginOptions = new DynamicOptions(bean, injector, dynamicBeans);
+   *    DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans);
+   *    pluginOptions.setBean(bean);
+   *    pluginOptions.startLifecycleListeners();
    *    pluginOptions.parseDynamicBeans(clp);
    *    pluginOptions.setDynamicBeans();
    *    pluginOptions.onBeanParseStart();
@@ -206,10 +213,15 @@
    *    pluginOptions.onBeanParseEnd();
    * </pre>
    */
-  public DynamicOptions(Object bean, Injector injector, DynamicMap<DynamicBean> dynamicBeans) {
-    this.bean = bean;
+  public DynamicOptions(Injector injector, DynamicMap<DynamicBean> dynamicBeans) {
     this.injector = injector;
+    this.dynamicBeans = dynamicBeans;
+    lifecycleManager = new LifecycleManager();
     beansByPlugin = new HashMap<>();
+  }
+
+  public void setBean(Object bean) {
+    this.bean = bean;
     Class<?> beanClass =
         (bean instanceof BeanReceiver)
             ? ((BeanReceiver) bean).getExportedBeanReceiver()
@@ -255,9 +267,10 @@
             modules.add(modulesInjector.getInstance(mClass));
           }
         }
-        return modulesInjector
-            .createChildInjector(modules)
-            .getInstance((Class<DynamicOptions.DynamicBean>) loader.loadClass(className));
+        Injector childModulesInjector = modulesInjector.createChildInjector(modules);
+        lifecycleManager.add(childModulesInjector);
+        return childModulesInjector.getInstance(
+            (Class<DynamicOptions.DynamicBean>) loader.loadClass(className));
       } catch (ClassNotFoundException e) {
         throw new RuntimeException(e);
       }
@@ -300,6 +313,14 @@
     }
   }
 
+  public void startLifecycleListeners() {
+    lifecycleManager.start();
+  }
+
+  public void stopLifecycleListeners() {
+    lifecycleManager.stop();
+  }
+
   public void onBeanParseStart() {
     for (Map.Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
       DynamicBean instance = e.getValue();
@@ -319,4 +340,9 @@
       }
     }
   }
+
+  @Override
+  public void close() {
+    stopLifecycleListeners();
+  }
 }
diff --git a/java/com/google/gerrit/server/ExceptionHookImpl.java b/java/com/google/gerrit/server/ExceptionHookImpl.java
index 8884991..3986842 100644
--- a/java/com/google/gerrit/server/ExceptionHookImpl.java
+++ b/java/com/google/gerrit/server/ExceptionHookImpl.java
@@ -18,6 +18,7 @@
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.project.ProjectConfig;
 import java.util.Optional;
@@ -73,6 +74,9 @@
               + "\n"
               + CONTACT_PROJECT_OWNER_USER_MESSAGE);
     }
+    if (throwable instanceof InternalServerWithUserMessageException) {
+      return ImmutableList.of(throwable.getMessage());
+    }
     return ImmutableList.of();
   }
 
diff --git a/java/com/google/gerrit/server/ExternalUser.java b/java/com/google/gerrit/server/ExternalUser.java
new file mode 100644
index 0000000..9680f3e
--- /dev/null
+++ b/java/com/google/gerrit/server/ExternalUser.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.common.flogger.LazyArgs.lazy;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collection;
+
+/**
+ * Represents a user that does not have a Gerrit account.
+ *
+ * <p>This user is limited in what they can do on Gerrit. For now, we only guarantee that permission
+ * checking - including ref filtering works.
+ *
+ * <p>This class is thread-safe.
+ */
+public class ExternalUser extends CurrentUser {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    ExternalUser create(
+        Collection<String> emailAddresses,
+        Collection<ExternalId.Key> externalIdKeys,
+        PropertyMap propertyMap);
+  }
+
+  private final GroupBackend groupBackend;
+  private final ImmutableSet<String> emailAddresses;
+  private final ImmutableSet<ExternalId.Key> externalIdKeys;
+
+  private GroupMembership effectiveGroups;
+
+  @Inject
+  public ExternalUser(
+      GroupBackend groupBackend,
+      @Assisted Collection<String> emailAddresses,
+      @Assisted Collection<ExternalId.Key> externalIdKeys,
+      @Assisted PropertyMap propertyMap) {
+    super(propertyMap);
+    this.groupBackend = groupBackend;
+    this.emailAddresses = ImmutableSet.copyOf(emailAddresses);
+    this.externalIdKeys = ImmutableSet.copyOf(externalIdKeys);
+  }
+
+  @Override
+  public ImmutableSet<String> getEmailAddresses() {
+    return emailAddresses;
+  }
+
+  @Override
+  public ImmutableSet<ExternalId.Key> getExternalIdKeys() {
+    return externalIdKeys;
+  }
+
+  @Override
+  public GroupMembership getEffectiveGroups() {
+    synchronized (this) {
+      if (effectiveGroups == null) {
+        effectiveGroups = groupBackend.membershipsOf(this);
+        logger.atFinest().log(
+            "Known groups of %s: %s", getLoggableName(), lazy(effectiveGroups::getKnownGroups));
+      }
+    }
+    return effectiveGroups;
+  }
+
+  @Override
+  public Object getCacheKey() {
+    return this; // Caching is tied to this exact instance.
+  }
+}
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 7cafdc0..24ea9d2 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -15,13 +15,16 @@
 package com.google.gerrit.server;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.flogger.LazyArgs.lazy;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
@@ -29,10 +32,11 @@
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.EnableReverseDnsLookup;
+import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 import com.google.inject.OutOfScopeException;
@@ -46,8 +50,6 @@
 import java.net.SocketAddress;
 import java.net.URL;
 import java.util.Date;
-import java.util.HashMap;
-import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.TimeZone;
@@ -67,7 +69,7 @@
     private final Provider<String> canonicalUrl;
     private final AccountCache accountCache;
     private final GroupBackend groupBackend;
-    private final Boolean enableReverseDnsLookup;
+    private final Boolean enablePeerIPInReflogRecord;
 
     @Inject
     public GenericFactory(
@@ -75,7 +77,7 @@
         Realm realm,
         @AnonymousCowardName String anonymousCowardName,
         @CanonicalWebUrl Provider<String> canonicalUrl,
-        @EnableReverseDnsLookup Boolean enableReverseDnsLookup,
+        @EnablePeerIPInReflogRecord Boolean enablePeerIPInReflogRecord,
         AccountCache accountCache,
         GroupBackend groupBackend) {
       this.authConfig = authConfig;
@@ -84,7 +86,7 @@
       this.canonicalUrl = canonicalUrl;
       this.accountCache = accountCache;
       this.groupBackend = groupBackend;
-      this.enableReverseDnsLookup = enableReverseDnsLookup;
+      this.enablePeerIPInReflogRecord = enablePeerIPInReflogRecord;
     }
 
     public IdentifiedUser create(AccountState state) {
@@ -95,7 +97,7 @@
           canonicalUrl,
           accountCache,
           groupBackend,
-          enableReverseDnsLookup,
+          enablePeerIPInReflogRecord,
           Providers.of(null),
           state,
           null);
@@ -105,12 +107,26 @@
       return create(null, id);
     }
 
+    @VisibleForTesting
+    @UsedAt(UsedAt.Project.GOOGLE)
+    public IdentifiedUser forTest(Account.Id id, PropertyMap properties) {
+      return runAs(null, id, null, properties);
+    }
+
     public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
       return runAs(remotePeer, id, null);
     }
 
     public IdentifiedUser runAs(
         SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
+      return runAs(remotePeer, id, caller, PropertyMap.EMPTY);
+    }
+
+    private IdentifiedUser runAs(
+        SocketAddress remotePeer,
+        Account.Id id,
+        @Nullable CurrentUser caller,
+        PropertyMap properties) {
       return new IdentifiedUser(
           authConfig,
           realm,
@@ -118,10 +134,11 @@
           canonicalUrl,
           accountCache,
           groupBackend,
-          enableReverseDnsLookup,
+          enablePeerIPInReflogRecord,
           Providers.of(remotePeer),
           id,
-          caller);
+          caller,
+          properties);
     }
   }
 
@@ -139,7 +156,7 @@
     private final Provider<String> canonicalUrl;
     private final AccountCache accountCache;
     private final GroupBackend groupBackend;
-    private final Boolean enableReverseDnsLookup;
+    private final Boolean enablePeerIPInReflogRecord;
     private final Provider<SocketAddress> remotePeerProvider;
 
     @Inject
@@ -150,7 +167,7 @@
         @CanonicalWebUrl Provider<String> canonicalUrl,
         AccountCache accountCache,
         GroupBackend groupBackend,
-        @EnableReverseDnsLookup Boolean enableReverseDnsLookup,
+        @EnablePeerIPInReflogRecord Boolean enablePeerIPInReflogRecord,
         @RemotePeer Provider<SocketAddress> remotePeerProvider) {
       this.authConfig = authConfig;
       this.realm = realm;
@@ -158,25 +175,15 @@
       this.canonicalUrl = canonicalUrl;
       this.accountCache = accountCache;
       this.groupBackend = groupBackend;
-      this.enableReverseDnsLookup = enableReverseDnsLookup;
+      this.enablePeerIPInReflogRecord = enablePeerIPInReflogRecord;
       this.remotePeerProvider = remotePeerProvider;
     }
 
     public IdentifiedUser create(Account.Id id) {
-      return new IdentifiedUser(
-          authConfig,
-          realm,
-          anonymousCowardName,
-          canonicalUrl,
-          accountCache,
-          groupBackend,
-          enableReverseDnsLookup,
-          remotePeerProvider,
-          id,
-          null);
+      return create(id, PropertyMap.EMPTY);
     }
 
-    public IdentifiedUser runAs(Account.Id id, CurrentUser caller) {
+    public <T> IdentifiedUser create(Account.Id id, PropertyMap properties) {
       return new IdentifiedUser(
           authConfig,
           realm,
@@ -184,10 +191,26 @@
           canonicalUrl,
           accountCache,
           groupBackend,
-          enableReverseDnsLookup,
+          enablePeerIPInReflogRecord,
           remotePeerProvider,
           id,
-          caller);
+          null,
+          properties);
+    }
+
+    public IdentifiedUser runAs(Account.Id id, CurrentUser caller, PropertyMap properties) {
+      return new IdentifiedUser(
+          authConfig,
+          realm,
+          anonymousCowardName,
+          canonicalUrl,
+          accountCache,
+          groupBackend,
+          enablePeerIPInReflogRecord,
+          remotePeerProvider,
+          id,
+          caller,
+          properties);
     }
   }
 
@@ -201,7 +224,7 @@
   private final Realm realm;
   private final GroupBackend groupBackend;
   private final String anonymousCowardName;
-  private final Boolean enableReverseDnsLookup;
+  private final Boolean enablePeerIPInReflogRecord;
   private final Set<String> validEmails = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
   private final CurrentUser realUser; // Must be final since cached properties depend on it.
 
@@ -212,7 +235,6 @@
   private boolean loadedAllEmails;
   private Set<String> invalidEmails;
   private GroupMembership effectiveGroups;
-  private Map<PropertyKey<Object>, Object> properties;
 
   private IdentifiedUser(
       AuthConfig authConfig,
@@ -221,7 +243,7 @@
       Provider<String> canonicalUrl,
       AccountCache accountCache,
       GroupBackend groupBackend,
-      Boolean enableReverseDnsLookup,
+      Boolean enablePeerIPInReflogRecord,
       @Nullable Provider<SocketAddress> remotePeerProvider,
       AccountState state,
       @Nullable CurrentUser realUser) {
@@ -232,10 +254,11 @@
         canonicalUrl,
         accountCache,
         groupBackend,
-        enableReverseDnsLookup,
+        enablePeerIPInReflogRecord,
         remotePeerProvider,
         state.account().id(),
-        realUser);
+        realUser,
+        PropertyMap.EMPTY);
     this.state = state;
   }
 
@@ -246,17 +269,19 @@
       Provider<String> canonicalUrl,
       AccountCache accountCache,
       GroupBackend groupBackend,
-      Boolean enableReverseDnsLookup,
+      Boolean enablePeerIPInReflogRecord,
       @Nullable Provider<SocketAddress> remotePeerProvider,
       Account.Id id,
-      @Nullable CurrentUser realUser) {
+      @Nullable CurrentUser realUser,
+      PropertyMap properties) {
+    super(properties);
     this.canonicalUrl = canonicalUrl;
     this.accountCache = accountCache;
     this.groupBackend = groupBackend;
     this.authConfig = authConfig;
     this.realm = realm;
     this.anonymousCowardName = anonymousCowardName;
-    this.enableReverseDnsLookup = enableReverseDnsLookup;
+    this.enablePeerIPInReflogRecord = enablePeerIPInReflogRecord;
     this.remotePeerProvider = remotePeerProvider;
     this.accountId = id;
     this.realUser = realUser != null ? realUser : this;
@@ -357,6 +382,7 @@
     return false;
   }
 
+  @Override
   public ImmutableSet<String> getEmailAddresses() {
     if (!loadedAllEmails) {
       validEmails.addAll(realm.getEmailAddresses(this));
@@ -365,6 +391,11 @@
     return ImmutableSet.copyOf(validEmails);
   }
 
+  @Override
+  public ImmutableSet<ExternalId.Key> getExternalIdKeys() {
+    return state().externalIds().stream().map(ExternalId::key).collect(toImmutableSet());
+  }
+
   public String getName() {
     return getAccount().getName();
   }
@@ -410,8 +441,20 @@
       name = anonymousCowardName;
     }
 
-    String user = getUserName().orElse("") + "|account-" + ua.id().toString();
-    return new PersonIdent(name, user + "@" + guessHost(), when, tz);
+    String user;
+    if (enablePeerIPInReflogRecord) {
+      user = constructMailAddress(ua, guessHost());
+    } else {
+      user =
+          Strings.isNullOrEmpty(ua.preferredEmail())
+              ? constructMailAddress(ua, "unknown")
+              : ua.preferredEmail();
+    }
+    return new PersonIdent(name, user, when, tz);
+  }
+
+  private String constructMailAddress(Account ua, String host) {
+    return getUserName().orElse("") + "|account-" + ua.id().toString() + "@" + host;
   }
 
   public PersonIdent newCommitterIdent(Date when, TimeZone tz) {
@@ -463,40 +506,6 @@
     return true;
   }
 
-  @Override
-  public synchronized <T> Optional<T> get(PropertyKey<T> key) {
-    if (properties != null) {
-      @SuppressWarnings("unchecked")
-      T value = (T) properties.get(key);
-      return Optional.ofNullable(value);
-    }
-    return Optional.empty();
-  }
-
-  /**
-   * Store a property for later retrieval.
-   *
-   * @param key unique property key.
-   * @param value value to store; or {@code null} to clear the value.
-   */
-  @Override
-  public synchronized <T> void put(PropertyKey<T> key, @Nullable T value) {
-    if (properties == null) {
-      if (value == null) {
-        return;
-      }
-      properties = new HashMap<>();
-    }
-
-    @SuppressWarnings("unchecked")
-    PropertyKey<Object> k = (PropertyKey<Object>) key;
-    if (value != null) {
-      properties.put(k, value);
-    } else {
-      properties.remove(k);
-    }
-  }
-
   /**
    * Returns a materialized copy of the user with all dependencies.
    *
@@ -522,7 +531,7 @@
         Providers.of(canonicalUrl.get()),
         accountCache,
         groupBackend,
-        enableReverseDnsLookup,
+        enablePeerIPInReflogRecord,
         remotePeer,
         state,
         realUser);
@@ -544,18 +553,11 @@
     if (remotePeer instanceof InetSocketAddress) {
       InetSocketAddress sa = (InetSocketAddress) remotePeer;
       InetAddress in = sa.getAddress();
-      host = in != null ? getHost(in) : sa.getHostName();
+      host = in != null ? in.getHostAddress() : sa.getHostName();
     }
     if (Strings.isNullOrEmpty(host)) {
       return "unknown";
     }
     return host;
   }
-
-  private String getHost(InetAddress in) {
-    if (Boolean.TRUE.equals(enableReverseDnsLookup)) {
-      return in.getCanonicalHostName();
-    }
-    return in.getHostAddress();
-  }
 }
diff --git a/java/com/google/gerrit/server/PropertyMap.java b/java/com/google/gerrit/server/PropertyMap.java
new file mode 100644
index 0000000..da3a2495
--- /dev/null
+++ b/java/com/google/gerrit/server/PropertyMap.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Optional;
+
+/**
+ * Immutable map that holds a collection of random objects allowing for a type-safe retrieval.
+ *
+ * <p>Intended to be used in {@link CurrentUser} when the object is constructed during login and
+ * holds per-request state. This functionality allows plugins/extensions to contribute specific data
+ * to {@link CurrentUser} that is unknown to Gerrit core.
+ */
+public class PropertyMap {
+  /** Empty instance to be referenced once per JVM. */
+  public static final PropertyMap EMPTY = builder().build();
+
+  /**
+   * Typed key for {@link PropertyMap}. This class intentionally does not implement {@link
+   * Object#equals(Object)} and {@link Object#hashCode()} so that the same instance has to be used
+   * to retrieve a stored value.
+   *
+   * <p>We require the exact same key instance because {@link PropertyMap} is implemented in a
+   * type-safe fashion by using Java generics to guarantee the return type. The generic type can't
+   * be recovered at runtime, so there is no way to just use the type's full name as key - we'd have
+   * to pass additional arguments. At the same time, this is in-line with how we'd want callers to
+   * use {@link PropertyMap}: Instantiate a static, per-JVM key that is reused when setting and
+   * getting values.
+   */
+  public static class Key<T> {}
+
+  public static <T> Key<T> key() {
+    return new Key<>();
+  }
+
+  public static class Builder {
+    private ImmutableMap.Builder<Object, Object> mutableMap;
+
+    private Builder() {
+      this.mutableMap = ImmutableMap.builder();
+    }
+
+    /** Adds the provided {@code value} to the {@link PropertyMap} that is being built. */
+    public <T> Builder put(Key<T> key, T value) {
+      mutableMap.put(key, value);
+      return this;
+    }
+
+    /** Builds and returns an immutable {@link PropertyMap}. */
+    public PropertyMap build() {
+      return new PropertyMap(mutableMap.build());
+    }
+  }
+
+  private final ImmutableMap<Object, Object> map;
+
+  private PropertyMap(ImmutableMap<Object, Object> map) {
+    this.map = map;
+  }
+
+  /** Returns a new {@link Builder} instance. */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /** Returns the requested value wrapped as {@link Optional}. */
+  @SuppressWarnings("unchecked")
+  public <T> Optional<T> get(Key<T> key) {
+    return Optional.ofNullable((T) map.get(key));
+  }
+}
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 88b0b21..785cd1c 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -86,31 +86,43 @@
   /**
    * @param project Project name.
    * @param commit SHA1 of commit.
+   * @param commitMessage the commit message of the commit.
+   * @param branchName branch of the commit.
    * @return Links for patch sets.
    */
-  public ImmutableList<WebLinkInfo> getPatchSetLinks(Project.NameKey project, String commit) {
-    return filterLinks(patchSetLinks, webLink -> webLink.getPatchSetWebLink(project.get(), commit));
+  public ImmutableList<WebLinkInfo> getPatchSetLinks(
+      Project.NameKey project, String commit, String commitMessage, String branchName) {
+    return filterLinks(
+        patchSetLinks,
+        webLink -> webLink.getPatchSetWebLink(project.get(), commit, commitMessage, branchName));
   }
 
   /**
    * @param project Project name.
    * @param revision SHA1 of the parent revision.
+   * @param commitMessage the commit message of the parent revision.
+   * @param branchName branch of the revision (and parent revision).
    * @return Links for patch sets.
    */
-  public ImmutableList<WebLinkInfo> getParentLinks(Project.NameKey project, String revision) {
-    return filterLinks(parentLinks, webLink -> webLink.getParentWebLink(project.get(), revision));
+  public ImmutableList<WebLinkInfo> getParentLinks(
+      Project.NameKey project, String revision, String commitMessage, String branchName) {
+    return filterLinks(
+        parentLinks,
+        webLink -> webLink.getParentWebLink(project.get(), revision, commitMessage, branchName));
   }
 
   /**
    * @param project Project name.
-   * @param revision SHA1 of revision.
+   * @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 file) {
+  public ImmutableList<WebLinkInfo> getFileLinks(
+      String project, String revision, String hash, String file) {
     return Patch.isMagic(file)
         ? ImmutableList.of()
-        : filterLinks(fileLinks, webLink -> webLink.getFileWebLink(project, revision, file));
+        : filterLinks(fileLinks, webLink -> webLink.getFileWebLink(project, revision, hash, file));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index f68a1c7..93e04880 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -19,6 +19,7 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
@@ -113,20 +114,21 @@
                 ? defaultPreferenceCache.get(ref.getObjectId())
                 : DefaultPreferencesCache.EMPTY;
 
-        ImmutableMap.Builder<Account.Id, AccountState> result = ImmutableMap.builder();
+        Set<CachedAccountDetails.Key> keys =
+            Sets.newLinkedHashSetWithExpectedSize(accountIds.size());
         for (Account.Id id : accountIds) {
           Ref userRef = allUsers.exactRef(RefNames.refsUsers(id));
           if (userRef == null) {
             continue;
           }
-
+          keys.add(CachedAccountDetails.Key.create(id, userRef.getObjectId()));
+        }
+        ImmutableMap.Builder<Account.Id, AccountState> result = ImmutableMap.builder();
+        for (Map.Entry<CachedAccountDetails.Key, CachedAccountDetails> account :
+            accountDetailsCache.getAll(keys).entrySet()) {
           result.put(
-              id,
-              AccountState.forCachedAccount(
-                  accountDetailsCache.get(
-                      CachedAccountDetails.Key.create(id, userRef.getObjectId())),
-                  defaultPreferences,
-                  externalIds));
+              account.getKey().accountId(),
+              AccountState.forCachedAccount(account.getValue(), defaultPreferences, externalIds));
         }
         return result.build();
       }
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 4dfeab5..2665b9a 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -495,7 +495,7 @@
   }
 
   /**
-   * Resolves all accounts matching the input string.
+   * Resolves all accounts matching the input string, visible to the current user.
    *
    * <p>The following input formats are recognized:
    *
diff --git a/java/com/google/gerrit/server/account/GroupBackend.java b/java/com/google/gerrit/server/account/GroupBackend.java
index 545da6e..d6360c5 100644
--- a/java/com/google/gerrit/server/account/GroupBackend.java
+++ b/java/com/google/gerrit/server/account/GroupBackend.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.project.ProjectState;
 import java.util.Collection;
 
@@ -42,7 +42,7 @@
   Collection<GroupReference> suggest(String name, @Nullable ProjectState project);
 
   /** @return the group membership checker for the backend. */
-  GroupMembership membershipsOf(IdentifiedUser user);
+  GroupMembership membershipsOf(CurrentUser user);
 
   /** @return {@code true} if the group with the given UUID is visible to all registered users. */
   boolean isVisibleToAll(AccountGroup.UUID uuid);
diff --git a/java/com/google/gerrit/server/account/GroupCache.java b/java/com/google/gerrit/server/account/GroupCache.java
index 90d3aa9..aaae95a 100644
--- a/java/com/google/gerrit/server/account/GroupCache.java
+++ b/java/com/google/gerrit/server/account/GroupCache.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.entities.InternalGroup;
+import java.util.Collection;
+import java.util.Map;
 import java.util.Optional;
 
 /** Tracks group objects in memory for efficient access. */
@@ -48,6 +50,18 @@
   Optional<InternalGroup> get(AccountGroup.UUID groupUuid);
 
   /**
+   * Returns a {@code Map} of {@code AccountGroup.UUID} to {@code InternalGroup} for the given
+   * groups UUIDs. If not cached yet the groups are loaded. If a group can't be loaded (e.g. because
+   * it is missing), the entry will be missing from the result.
+   *
+   * @param groupUuids UUIDs of the groups that should be retrieved
+   * @return {@code Map} of {@code AccountGroup.UUID} to {@code InternalGroup} instances for the
+   *     given group UUIDs, if a group can't be loaded (e.g. because it is missing), the entry will
+   *     be missing from the result.
+   */
+  Map<AccountGroup.UUID, InternalGroup> get(Collection<AccountGroup.UUID> groupUuids);
+
+  /**
    * Removes the association of the given ID with a group.
    *
    * <p>The next call to {@link #get(AccountGroup.Id)} won't provide a cached value.
@@ -88,4 +102,7 @@
    * @param groupUuid the UUID of a possibly associated group
    */
   void evict(AccountGroup.UUID groupUuid);
+
+  /** @see #evict(AccountGroup.UUID); */
+  void evict(Collection<AccountGroup.UUID> groupUuid);
 }
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index fe22028..eaec9ba 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -14,12 +14,27 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.ProtobufSerializer;
+import com.google.gerrit.server.cache.serialize.entities.InternalGroupSerializer;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
@@ -31,8 +46,19 @@
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import org.bouncycastle.util.Strings;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 
 /** Tracks group objects in memory for efficient access. */
 @Singleton
@@ -42,6 +68,7 @@
   private static final String BYID_NAME = "groups";
   private static final String BYNAME_NAME = "groups_byname";
   private static final String BYUUID_NAME = "groups_byuuid";
+  private static final String BYUUID_NAME_PERSISTED = "groups_byuuid_persisted";
 
   public static Module module() {
     return new CacheModule() {
@@ -55,9 +82,35 @@
             .maximumWeight(Long.MAX_VALUE)
             .loader(ByNameLoader.class);
 
+        // We split the group cache into two parts for performance reasons:
+        // 1) An in-memory part that has only the group ref uuid as key.
+        // 2) A persisted part that has the group ref uuid and sha1 of the ref as key.
+        //
+        // When loading dashboards or returning change query results we potentially
+        // need to access many groups.
+        // We want the persisted cache to be immutable and we want it to be impossible that a
+        // value for a given key is out of date. We therefore require the sha-1 in the key. That
+        // is in line with the rest of the caches in Gerrit.
+        //
+        // Splitting the cache into two chunks internally in this class allows us to retain
+        // the existing performance guarantees of not requiring reads for the repo for values
+        // cached in-memory but also to persist the cache which leads to a much improved
+        // cold-start behavior and in-memory miss latency.
+
         cache(BYUUID_NAME, String.class, new TypeLiteral<Optional<InternalGroup>>() {})
             .maximumWeight(Long.MAX_VALUE)
-            .loader(ByUUIDLoader.class);
+            .loader(ByUUIDInMemoryLoader.class);
+
+        persist(
+                BYUUID_NAME_PERSISTED,
+                Cache.GroupKeyProto.class,
+                new TypeLiteral<InternalGroup>() {})
+            .loader(PersistedByUUIDLoader.class)
+            .keySerializer(new ProtobufSerializer<>(Cache.GroupKeyProto.parser()))
+            .valueSerializer(PersistedInternalGroupSerializer.INSTANCE)
+            .diskLimit(1 << 30) // 1 GiB
+            .version(1)
+            .maximumWeight(0);
 
         bind(GroupCacheImpl.class);
         bind(GroupCache.class).to(GroupCacheImpl.class);
@@ -117,6 +170,20 @@
   }
 
   @Override
+  public Map<AccountGroup.UUID, InternalGroup> get(Collection<AccountGroup.UUID> groupUuids) {
+    try {
+      Set<String> groupUuidsStringSet =
+          groupUuids.stream().map(u -> u.get()).collect(toImmutableSet());
+      return byUUID.getAll(groupUuidsStringSet).entrySet().stream()
+          .filter(g -> g.getValue().isPresent())
+          .collect(toImmutableMap(g -> AccountGroup.uuid(g.getKey()), g -> g.getValue().get()));
+    } catch (ExecutionException e) {
+      logger.atWarning().withCause(e).log("Cannot look up groups %s by uuids", groupUuids);
+      return ImmutableMap.of();
+    }
+  }
+
+  @Override
   public void evict(AccountGroup.Id groupId) {
     if (groupId != null) {
       logger.atFine().log("Evict group %s by ID", groupId.get());
@@ -140,6 +207,14 @@
     }
   }
 
+  @Override
+  public void evict(Collection<AccountGroup.UUID> groupUuids) {
+    if (groupUuids != null && !groupUuids.isEmpty()) {
+      logger.atFine().log("Evict groups %s by UUID", groupUuids);
+      byUUID.invalidateAll(groupUuids);
+    }
+  }
+
   static class ByIdLoader extends CacheLoader<AccountGroup.Id, Optional<InternalGroup>> {
     private final Provider<InternalGroupQuery> groupQueryProvider;
 
@@ -150,7 +225,7 @@
 
     @Override
     public Optional<InternalGroup> load(AccountGroup.Id key) throws Exception {
-      try (TraceTimer timer =
+      try (TraceTimer ignored =
           TraceContext.newTimer(
               "Loading group by ID", Metadata.builder().groupId(key.get()).build())) {
         return groupQueryProvider.get().byId(key);
@@ -168,7 +243,7 @@
 
     @Override
     public Optional<InternalGroup> load(String name) throws Exception {
-      try (TraceTimer timer =
+      try (TraceTimer ignored =
           TraceContext.newTimer(
               "Loading group by name", Metadata.builder().groupName(name).build())) {
         return groupQueryProvider.get().byName(AccountGroup.nameKey(name));
@@ -176,21 +251,108 @@
     }
   }
 
-  static class ByUUIDLoader extends CacheLoader<String, Optional<InternalGroup>> {
-    private final Groups groups;
+  static class ByUUIDInMemoryLoader extends CacheLoader<String, Optional<InternalGroup>> {
+    private final LoadingCache<Cache.GroupKeyProto, InternalGroup> persistedCache;
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsersName;
 
     @Inject
-    ByUUIDLoader(Groups groups) {
-      this.groups = groups;
+    ByUUIDInMemoryLoader(
+        @Named(BYUUID_NAME_PERSISTED)
+            LoadingCache<Cache.GroupKeyProto, InternalGroup> persistedCache,
+        GitRepositoryManager repoManager,
+        AllUsersName allUsersName) {
+      this.persistedCache = persistedCache;
+      this.repoManager = repoManager;
+      this.allUsersName = allUsersName;
     }
 
     @Override
     public Optional<InternalGroup> load(String uuid) throws Exception {
-      try (TraceTimer timer =
-          TraceContext.newTimer(
-              "Loading group by UUID", Metadata.builder().groupUuid(uuid).build())) {
-        return groups.getGroup(AccountGroup.uuid(uuid));
+      return loadAll(ImmutableSet.of(uuid)).get(uuid);
+    }
+
+    @Override
+    public Map<String, Optional<InternalGroup>> loadAll(Iterable<? extends String> uuids)
+        throws Exception {
+      Map<String, Optional<InternalGroup>> toReturn = new HashMap<>();
+      if (Iterables.isEmpty(uuids)) {
+        return toReturn;
       }
+      Iterator<? extends String> uuidIterator = uuids.iterator();
+      List<Cache.GroupKeyProto> keyList = new ArrayList<>();
+      try (TraceTimer ignored =
+              TraceContext.newTimer(
+                  "Loading group from serialized cache",
+                  Metadata.builder().cacheName(BYUUID_NAME_PERSISTED).build());
+          Repository allUsers = repoManager.openRepository(allUsersName)) {
+        while (uuidIterator.hasNext()) {
+          String currentUuid = uuidIterator.next();
+          String ref = RefNames.refsGroups(AccountGroup.uuid(currentUuid));
+          Ref sha1 = allUsers.exactRef(ref);
+          if (sha1 == null) {
+            toReturn.put(currentUuid, Optional.empty());
+            continue;
+          }
+          Cache.GroupKeyProto key =
+              Cache.GroupKeyProto.newBuilder()
+                  .setUuid(currentUuid)
+                  .setRevision(ObjectIdConverter.create().toByteString(sha1.getObjectId()))
+                  .build();
+          keyList.add(key);
+        }
+      }
+      persistedCache.getAll(keyList).entrySet().stream()
+          .forEach(g -> toReturn.put(g.getKey().getUuid(), Optional.of(g.getValue())));
+      return toReturn;
+    }
+  }
+
+  static class PersistedByUUIDLoader extends CacheLoader<Cache.GroupKeyProto, InternalGroup> {
+    private final Groups groups;
+
+    @Inject
+    PersistedByUUIDLoader(Groups groups) {
+      this.groups = groups;
+    }
+
+    @Override
+    public InternalGroup load(Cache.GroupKeyProto key) throws Exception {
+      try (TraceTimer ignored =
+          TraceContext.newTimer(
+              "Loading group by UUID", Metadata.builder().groupUuid(key.getUuid()).build())) {
+        ObjectId sha1 = ObjectIdConverter.create().fromByteString(key.getRevision());
+        Optional<InternalGroup> loadedGroup =
+            groups.getGroup(AccountGroup.uuid(key.getUuid()), sha1);
+        if (!loadedGroup.isPresent()) {
+          throw new IllegalStateException(
+              String.format(
+                  "group %s should have the sha-1 %s, but " + "it was not found",
+                  key.getUuid(), sha1.getName()));
+        }
+        return loadedGroup.get();
+      }
+    }
+  }
+
+  private enum PersistedInternalGroupSerializer implements CacheSerializer<InternalGroup> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(InternalGroup value) {
+      if (value == null) {
+        return new byte[0];
+      }
+      return Protos.toByteArray(InternalGroupSerializer.serialize(value));
+    }
+
+    @Override
+    public InternalGroup deserialize(byte[] in) {
+      if (Strings.fromByteArray(in).isEmpty()) {
+        return null;
+      }
+      return InternalGroupSerializer.deserialize(
+          Protos.parseUnchecked(Cache.InternalGroupProto.parser(), in));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index 073ff84..f203240 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -25,13 +25,13 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalGroupsProto;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalGroupsProto.ExternalGroupProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
diff --git a/java/com/google/gerrit/server/account/GroupMembers.java b/java/com/google/gerrit/server/account/GroupMembers.java
index c03ffd0..3ed82a1 100644
--- a/java/com/google/gerrit/server/account/GroupMembers.java
+++ b/java/com/google/gerrit/server/account/GroupMembers.java
@@ -22,8 +22,8 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.NoSuchProjectException;
diff --git a/java/com/google/gerrit/server/account/IncludingGroupMembership.java b/java/com/google/gerrit/server/account/IncludingGroupMembership.java
index 6dc7976..8cec8bf 100644
--- a/java/com/google/gerrit/server/account/IncludingGroupMembership.java
+++ b/java/com/google/gerrit/server/account/IncludingGroupMembership.java
@@ -14,19 +14,19 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.server.CurrentUser;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 
@@ -40,18 +40,18 @@
  */
 public class IncludingGroupMembership implements GroupMembership {
   public interface Factory {
-    IncludingGroupMembership create(IdentifiedUser user);
+    IncludingGroupMembership create(CurrentUser user);
   }
 
   private final GroupCache groupCache;
   private final GroupIncludeCache includeCache;
-  private final IdentifiedUser user;
+  private final CurrentUser user;
   private final Map<AccountGroup.UUID, Boolean> memberOf;
   private Set<AccountGroup.UUID> knownGroups;
 
   @Inject
   IncludingGroupMembership(
-      GroupCache groupCache, GroupIncludeCache includeCache, @Assisted IdentifiedUser user) {
+      GroupCache groupCache, GroupIncludeCache includeCache, @Assisted CurrentUser user) {
     this.groupCache = groupCache;
     this.includeCache = includeCache;
     this.user = user;
@@ -82,6 +82,9 @@
     }
 
     if (tryExpanding) {
+      Set<AccountGroup.UUID> queryIdsSet = new HashSet<>();
+      queryIds.forEach(i -> queryIdsSet.add(i));
+      Map<AccountGroup.UUID, InternalGroup> groups = groupCache.get(queryIdsSet);
       for (AccountGroup.UUID id : queryIds) {
         if (memberOf.containsKey(id)) {
           // Membership was earlier proven to be false.
@@ -89,15 +92,15 @@
         }
 
         memberOf.put(id, false);
-        Optional<InternalGroup> group = groupCache.get(id);
-        if (!group.isPresent()) {
+        InternalGroup group = groups.get(id);
+        if (group == null) {
           continue;
         }
-        if (group.get().getMembers().contains(user.getAccountId())) {
+        if (user.isIdentifiedUser() && group.getMembers().contains(user.getAccountId())) {
           memberOf.put(id, true);
           return true;
         }
-        if (search(group.get().getSubgroups())) {
+        if (search(group.getSubgroups())) {
           memberOf.put(id, true);
           return true;
         }
@@ -124,7 +127,10 @@
 
   private ImmutableSet<AccountGroup.UUID> computeKnownGroups() {
     GroupMembership membership = user.getEffectiveGroups();
-    Collection<AccountGroup.UUID> direct = includeCache.getGroupsWithMember(user.getAccountId());
+    Collection<AccountGroup.UUID> direct =
+        user.isIdentifiedUser()
+            ? includeCache.getGroupsWithMember(user.getAccountId())
+            : ImmutableList.of();
     direct.forEach(groupUuid -> memberOf.put(groupUuid, true));
     Set<AccountGroup.UUID> r = Sets.newHashSet(direct);
     r.remove(null);
diff --git a/java/com/google/gerrit/server/account/InternalGroupBackend.java b/java/com/google/gerrit/server/account/InternalGroupBackend.java
index c520c96..91fe701 100644
--- a/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -20,8 +20,8 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsNoteDbConsistencyChecker;
@@ -97,7 +97,7 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
+  public GroupMembership membershipsOf(CurrentUser user) {
     return groupMembershipFactory.create(user);
   }
 
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifier.java b/java/com/google/gerrit/server/account/ServiceUserClassifier.java
index c8314c8..2d2a646 100644
--- a/java/com/google/gerrit/server/account/ServiceUserClassifier.java
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifier.java
@@ -17,6 +17,11 @@
 import com.google.gerrit.entities.Account;
 
 public interface ServiceUserClassifier {
+  /**
+   * Name of the Service Users group used by this class to determine whether an account is a service
+   * user; if an account is a part of this group, that account is considered a service user.
+   */
+  public static final String SERVICE_USERS = "Service Users";
   /** Returns {@code true} if the given user is considered a {@code Service User} user. */
   boolean isServiceUser(Account.Id user);
 
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
index 255467c..27ac9f4 100644
--- a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
@@ -17,8 +17,8 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
 import com.google.inject.Scopes;
@@ -63,7 +63,7 @@
 
   @Override
   public boolean isServiceUser(Account.Id user) {
-    Optional<InternalGroup> maybeGroup = groupCache.get(AccountGroup.nameKey("Service Users"));
+    Optional<InternalGroup> maybeGroup = groupCache.get(AccountGroup.nameKey(SERVICE_USERS));
     if (!maybeGroup.isPresent()) {
       return false;
     }
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index a35b0ac..5bd9bea 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -94,14 +94,14 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
+  public GroupMembership membershipsOf(CurrentUser user) {
     return new UniversalGroupMembership(user);
   }
 
   private class UniversalGroupMembership implements GroupMembership {
     private final Map<GroupBackend, GroupMembership> memberships;
 
-    private UniversalGroupMembership(IdentifiedUser user) {
+    private UniversalGroupMembership(CurrentUser user) {
       ImmutableMap.Builder<GroupBackend, GroupMembership> builder = ImmutableMap.builder();
       backends.runEach(g -> builder.put(g, g.membershipsOf(user)));
       this.memberships = builder.build();
diff --git a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index 235537c..30021e6 100644
--- a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -61,6 +61,8 @@
  * <p>Other comment lines are ignored on read, and are not written back when the file is modified.
  */
 public class VersionedAuthorizedKeys extends VersionedMetaData {
+
+  /** Read/write SSH keys by user ID. */
   @Singleton
   public static class Accessor {
     private final GitRepositoryManager repoManager;
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 4f85412..1eee10f 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -365,8 +365,7 @@
   @Override
   public void starChange(String changeId) throws RestApiException {
     try {
-      starredChangesCreate.apply(
-          account, IdString.fromUrl(changeId), new StarredChanges.EmptyInput());
+      starredChangesCreate.apply(account, IdString.fromUrl(changeId), new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot star change", e);
     }
@@ -378,7 +377,7 @@
       ChangeResource rsrc = changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
       AccountResource.StarredChange starredChange =
           new AccountResource.StarredChange(account.getUser(), rsrc);
-      starredChangesDelete.apply(starredChange, new StarredChanges.EmptyInput());
+      starredChangesDelete.apply(starredChange, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot unstar change", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 0992bcd..0b340b8 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInfoDifference;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
@@ -80,6 +81,7 @@
 import com.google.gerrit.server.restapi.change.GetAssignee;
 import com.google.gerrit.server.restapi.change.GetChange;
 import com.google.gerrit.server.restapi.change.GetHashtags;
+import com.google.gerrit.server.restapi.change.GetMetaDiff;
 import com.google.gerrit.server.restapi.change.GetPastAssignees;
 import com.google.gerrit.server.restapi.change.GetPureRevert;
 import com.google.gerrit.server.restapi.change.GetTopic;
@@ -149,6 +151,7 @@
   private final ChangeIncludedIn includedIn;
   private final PostReviewers postReviewers;
   private final Provider<GetChange> getChangeProvider;
+  private final Provider<GetMetaDiff> getMetaDiffProvider;
   private final PostHashtags postHashtags;
   private final GetHashtags getHashtags;
   private final AttentionSet attentionSet;
@@ -160,7 +163,7 @@
   private final DeleteAssignee deleteAssignee;
   private final Provider<ListChangeComments> listCommentsProvider;
   private final ListChangeRobotComments listChangeRobotComments;
-  private final ListChangeDrafts listDrafts;
+  private final Provider<ListChangeDrafts> listDraftsProvider;
   private final ChangeEditApiImpl.Factory changeEditApi;
   private final Check check;
   private final Index index;
@@ -177,6 +180,8 @@
   private final Provider<GetPureRevert> getPureRevertProvider;
   private final StarredChangesUtil stars;
   private final DynamicOptionParser dynamicOptionParser;
+  private final Injector injector;
+  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
   ChangeApiImpl(
@@ -202,6 +207,7 @@
       ChangeIncludedIn includedIn,
       PostReviewers postReviewers,
       Provider<GetChange> getChangeProvider,
+      Provider<GetMetaDiff> getMetaDiffProvider,
       PostHashtags postHashtags,
       GetHashtags getHashtags,
       AttentionSet attentionSet,
@@ -213,7 +219,7 @@
       DeleteAssignee deleteAssignee,
       Provider<ListChangeComments> listCommentsProvider,
       ListChangeRobotComments listChangeRobotComments,
-      ListChangeDrafts listDrafts,
+      Provider<ListChangeDrafts> listDraftsProvider,
       ChangeEditApiImpl.Factory changeEditApi,
       Check check,
       Index index,
@@ -230,7 +236,9 @@
       Provider<GetPureRevert> getPureRevertProvider,
       StarredChangesUtil stars,
       DynamicOptionParser dynamicOptionParser,
-      @Assisted ChangeResource change) {
+      @Assisted ChangeResource change,
+      Injector injector,
+      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
     this.changeApi = changeApi;
     this.revert = revert;
     this.revertSubmission = revertSubmission;
@@ -253,6 +261,7 @@
     this.includedIn = includedIn;
     this.postReviewers = postReviewers;
     this.getChangeProvider = getChangeProvider;
+    this.getMetaDiffProvider = getMetaDiffProvider;
     this.postHashtags = postHashtags;
     this.getHashtags = getHashtags;
     this.attentionSet = attentionSet;
@@ -264,7 +273,7 @@
     this.deleteAssignee = deleteAssignee;
     this.listCommentsProvider = listCommentsProvider;
     this.listChangeRobotComments = listChangeRobotComments;
-    this.listDrafts = listDrafts;
+    this.listDraftsProvider = listDraftsProvider;
     this.changeEditApi = changeEditApi;
     this.check = check;
     this.index = index;
@@ -282,6 +291,8 @@
     this.stars = stars;
     this.dynamicOptionParser = dynamicOptionParser;
     this.change = change;
+    this.injector = injector;
+    this.dynamicBeans = dynamicBeans;
   }
 
   @Override
@@ -500,10 +511,10 @@
   public ChangeInfo get(
       EnumSet<ListChangesOption> options, ImmutableListMultimap<String, String> pluginOptions)
       throws RestApiException {
-    try {
+    try (DynamicOptions dynamicOptions = new DynamicOptions(injector, dynamicBeans)) {
       GetChange getChange = getChangeProvider.get();
       options.forEach(getChange::addOption);
-      dynamicOptionParser.parseDynamicOptions(getChange, pluginOptions);
+      dynamicOptionParser.parseDynamicOptions(getChange, pluginOptions, dynamicOptions);
       return getChange.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve change", e);
@@ -511,6 +522,25 @@
   }
 
   @Override
+  public ChangeInfoDifference metaDiff(
+      @Nullable String oldMetaRevId,
+      @Nullable String newMetaRevId,
+      EnumSet<ListChangesOption> options,
+      ImmutableListMultimap<String, String> pluginOptions)
+      throws RestApiException {
+    try (DynamicOptions dynamicOptions = new DynamicOptions(injector, dynamicBeans)) {
+      GetMetaDiff metaDiff = getMetaDiffProvider.get();
+      metaDiff.setOldMetaRevId(oldMetaRevId);
+      metaDiff.setNewMetaRevId(newMetaRevId);
+      options.forEach(metaDiff::addOption);
+      dynamicOptionParser.parseDynamicOptions(metaDiff, pluginOptions, dynamicOptions);
+      return metaDiff.apply(change).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve metaDiff", e);
+    }
+  }
+
+  @Override
   public ChangeEditApi edit() throws RestApiException {
     return changeEditApi.create(change);
   }
@@ -599,13 +629,14 @@
   }
 
   @Override
-  public CommentsRequest commentsRequest() throws RestApiException {
+  public CommentsRequest commentsRequest() {
     return new CommentsRequest() {
       @Override
       public Map<String, List<CommentInfo>> get() throws RestApiException {
         try {
           ListChangeComments listComments = listCommentsProvider.get();
           listComments.setContext(this.getContext());
+          listComments.setContextPadding(this.getContextPadding());
           return listComments.apply(change).value();
         } catch (Exception e) {
           throw asRestApiException("Cannot get comments", e);
@@ -617,6 +648,7 @@
         try {
           ListChangeComments listComments = listCommentsProvider.get();
           listComments.setContext(this.getContext());
+          listComments.setContextPadding(this.getContextPadding());
           return listComments.getComments(change);
         } catch (Exception e) {
           throw asRestApiException("Cannot get comments", e);
@@ -635,21 +667,32 @@
   }
 
   @Override
-  public Map<String, List<CommentInfo>> drafts() throws RestApiException {
-    try {
-      return listDrafts.apply(change).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get drafts", e);
-    }
-  }
+  public DraftsRequest draftsRequest() {
+    return new DraftsRequest() {
+      @Override
+      public Map<String, List<CommentInfo>> get() throws RestApiException {
+        try {
+          ListChangeDrafts listDrafts = listDraftsProvider.get();
+          listDrafts.setContext(this.getContext());
+          listDrafts.setContextPadding(this.getContextPadding());
+          return listDrafts.apply(change).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot get drafts", e);
+        }
+      }
 
-  @Override
-  public List<CommentInfo> draftsAsList() throws RestApiException {
-    try {
-      return listDrafts.getComments(change);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get drafts", e);
-    }
+      @Override
+      public List<CommentInfo> getAsList() throws RestApiException {
+        try {
+          ListChangeDrafts listDrafts = listDraftsProvider.get();
+          listDrafts.setContext(this.getContext());
+          listDrafts.setContextPadding(this.getContextPadding());
+          return listDrafts.getComments(change);
+        } catch (Exception e) {
+          throw asRestApiException("Cannot get drafts", e);
+        }
+      }
+    };
   }
 
   @Override
@@ -759,23 +802,18 @@
   @Singleton
   static class DynamicOptionParser {
     private final CmdLineParser.Factory cmdLineParserFactory;
-    private final Injector injector;
-    private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
     @Inject
-    DynamicOptionParser(
-        CmdLineParser.Factory cmdLineParserFactory,
-        Injector injector,
-        DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
+    DynamicOptionParser(CmdLineParser.Factory cmdLineParserFactory) {
       this.cmdLineParserFactory = cmdLineParserFactory;
-      this.injector = injector;
-      this.dynamicBeans = dynamicBeans;
     }
 
-    void parseDynamicOptions(Object bean, ListMultimap<String, String> pluginOptions)
+    void parseDynamicOptions(
+        Object bean, ListMultimap<String, String> pluginOptions, DynamicOptions dynamicOptions)
         throws BadRequestException {
       CmdLineParser clp = cmdLineParserFactory.create(bean);
-      DynamicOptions dynamicOptions = new DynamicOptions(bean, injector, dynamicBeans);
+      dynamicOptions.setBean(bean);
+      dynamicOptions.startLifecycleListeners();
       dynamicOptions.parseDynamicBeans(clp);
       dynamicOptions.setDynamicBeans();
       dynamicOptions.onBeanParseStart();
diff --git a/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index d6ef61c..0596524 100644
--- a/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -26,15 +26,18 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.api.changes.ChangeApiImpl.DynamicOptionParser;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.server.restapi.change.CreateChange;
 import com.google.gerrit.server.restapi.change.QueryChanges;
 import com.google.inject.Inject;
+import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.List;
@@ -46,6 +49,8 @@
   private final CreateChange createChange;
   private final DynamicOptionParser dynamicOptionParser;
   private final Provider<QueryChanges> queryProvider;
+  private final Injector injector;
+  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
   ChangesImpl(
@@ -53,12 +58,16 @@
       ChangeApiImpl.Factory api,
       CreateChange createChange,
       DynamicOptionParser dynamicOptionParser,
-      Provider<QueryChanges> queryProvider) {
+      Provider<QueryChanges> queryProvider,
+      Injector injector,
+      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
     this.changes = changes;
     this.api = api;
     this.createChange = createChange;
     this.dynamicOptionParser = dynamicOptionParser;
     this.queryProvider = queryProvider;
+    this.injector = injector;
+    this.dynamicBeans = dynamicBeans;
   }
 
   @Override
@@ -123,34 +132,36 @@
   }
 
   private List<ChangeInfo> get(QueryRequest q) throws RestApiException {
-    QueryChanges qc = queryProvider.get();
-    if (q.getQuery() != null) {
-      qc.addQuery(q.getQuery());
-    }
-    qc.setLimit(q.getLimit());
-    qc.setStart(q.getStart());
-    qc.setNoLimit(q.getNoLimit());
-    for (ListChangesOption option : q.getOptions()) {
-      qc.addOption(option);
-    }
-    dynamicOptionParser.parseDynamicOptions(qc, q.getPluginOptions());
-
-    try {
-      List<?> result = qc.apply(TopLevelResource.INSTANCE).value();
-      if (result.isEmpty()) {
-        return ImmutableList.of();
+    try (DynamicOptions dynamicOptions = new DynamicOptions(injector, dynamicBeans)) {
+      QueryChanges qc = queryProvider.get();
+      if (q.getQuery() != null) {
+        qc.addQuery(q.getQuery());
       }
+      qc.setLimit(q.getLimit());
+      qc.setStart(q.getStart());
+      qc.setNoLimit(q.getNoLimit());
+      for (ListChangesOption option : q.getOptions()) {
+        qc.addOption(option);
+      }
+      dynamicOptionParser.parseDynamicOptions(qc, q.getPluginOptions(), dynamicOptions);
 
-      // Check type safety of result; the extension API should be safer than the
-      // REST API in this case, since it's intended to be used in Java.
-      Object first = requireNonNull(result.iterator().next());
-      checkState(first instanceof ChangeInfo);
-      @SuppressWarnings("unchecked")
-      List<ChangeInfo> infos = (List<ChangeInfo>) result;
+      try {
+        List<?> result = qc.apply(TopLevelResource.INSTANCE).value();
+        if (result.isEmpty()) {
+          return ImmutableList.of();
+        }
 
-      return ImmutableList.copyOf(infos);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot query changes", e);
+        // Check type safety of result; the extension API should be safer than the
+        // REST API in this case, since it's intended to be used in Java.
+        Object first = requireNonNull(result.iterator().next());
+        checkState(first instanceof ChangeInfo);
+        @SuppressWarnings("unchecked")
+        List<ChangeInfo> infos = (List<ChangeInfo>) result;
+
+        return ImmutableList.copyOf(infos);
+      } catch (Exception e) {
+        throw asRestApiException("Cannot query changes", e);
+      }
     }
   }
 }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 04d2e8ae..573f2f5 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -106,7 +106,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-class RevisionApiImpl implements RevisionApi {
+class RevisionApiImpl extends RevisionApi.NotImplemented {
   interface Factory {
     RevisionApiImpl create(RevisionResource r);
   }
@@ -282,7 +282,16 @@
   @Override
   public ChangeApi rebase(RebaseInput in) throws RestApiException {
     try {
-      return changes.id(rebase.apply(revision, in).value()._number);
+      return changes.id(rebaseAsInfo(in)._number);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase ps", e);
+    }
+  }
+
+  @Override
+  public ChangeInfo rebaseAsInfo(RebaseInput in) throws RestApiException {
+    try {
+      return rebase.apply(revision, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot rebase ps", e);
     }
@@ -678,11 +687,6 @@
   }
 
   @Override
-  public String etag() throws RestApiException {
-    return revisionActions.getETag(revision);
-  }
-
-  @Override
   public BinaryResult getArchive(ArchiveFormat format) throws RestApiException {
     GetArchive getArchive = getArchiveProvider.get();
     getArchive.setFormat(format != null ? format.name().toLowerCase(Locale.US) : null);
diff --git a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index c7cca6f..78f5c5f 100644
--- a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -126,6 +127,10 @@
 
   private BranchResource resource()
       throws RestApiException, IOException, PermissionBackendException {
-    return branches.parse(project, IdString.fromDecoded(ref));
+    String refName = ref;
+    if (RefNames.isRefsUsersSelf(ref, project.getProjectState().isAllUsers())) {
+      refName = RefNames.refsUsers(project.getUser().getAccountId());
+    }
+    return branches.parse(project, IdString.fromDecoded(refName));
   }
 }
diff --git a/java/com/google/gerrit/server/api/projects/CommitApiImpl.java b/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
index 5c7921a..e055a00 100644
--- a/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
@@ -22,13 +22,16 @@
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.api.projects.CommitApi;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.CommitResource;
 import com.google.gerrit.server.restapi.change.CherryPickCommit;
 import com.google.gerrit.server.restapi.project.CommitIncludedIn;
+import com.google.gerrit.server.restapi.project.FilesInCommitCollection;
 import com.google.gerrit.server.restapi.project.GetCommit;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.util.Map;
 
 public class CommitApiImpl implements CommitApi {
   public interface Factory {
@@ -40,6 +43,7 @@
   private final CherryPickCommit cherryPickCommit;
   private final CommitIncludedIn includedIn;
   private final CommitResource commitResource;
+  private final FilesInCommitCollection.ListFiles listFiles;
 
   @Inject
   CommitApiImpl(
@@ -47,11 +51,13 @@
       GetCommit getCommit,
       CherryPickCommit cherryPickCommit,
       CommitIncludedIn includedIn,
+      FilesInCommitCollection.ListFiles listFiles,
       @Assisted CommitResource commitResource) {
     this.changes = changes;
     this.getCommit = getCommit;
     this.cherryPickCommit = cherryPickCommit;
     this.includedIn = includedIn;
+    this.listFiles = listFiles;
     this.commitResource = commitResource;
   }
 
@@ -81,4 +87,13 @@
       throw asRestApiException("Could not extract IncludedIn data", e);
     }
   }
+
+  @Override
+  public Map<String, FileInfo> files(int parentNum) throws RestApiException {
+    try {
+      return listFiles.setParent(parentNum).apply(commitResource).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve files", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
index 66d0224..0cedf19 100644
--- a/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
@@ -17,8 +17,8 @@
 import static com.google.gerrit.util.cli.Localizable.localizable;
 
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
index e88f6df..d3ed3e4 100644
--- a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
@@ -19,10 +19,10 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
diff --git a/java/com/google/gerrit/server/audit/BUILD b/java/com/google/gerrit/server/audit/BUILD
index 0870786..3faa259 100644
--- a/java/com/google/gerrit/server/audit/BUILD
+++ b/java/com/google/gerrit/server/audit/BUILD
@@ -46,7 +46,6 @@
         "//lib:guava-retrying",
         "//lib:jgit",
         "//lib:jgit-archive",
-        "//lib:jsch",
         "//lib:juniversalchardet",
         "//lib:mime-util",
         "//lib:protobuf",
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
new file mode 100644
index 0000000..9553acc
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Base class for persistent cache factory. If the cache.directory property is unset, or disk limit
+ * is zero or negative, it will fall back to in-memory only caches.
+ */
+public abstract class PersistentCacheBaseFactory implements PersistentCacheFactory {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  protected final MemoryCacheFactory memCacheFactory;
+  protected final Path cacheDir;
+  protected boolean diskEnabled;
+  protected final Config config;
+
+  public PersistentCacheBaseFactory(
+      MemoryCacheFactory memCacheFactory, @GerritServerConfig Config config, SitePaths site) {
+    this.cacheDir = getCacheDir(site, config.getString("cache", null, "directory"));
+    this.diskEnabled = cacheDir != null;
+    this.memCacheFactory = memCacheFactory;
+    this.config = config;
+  }
+
+  protected abstract <K, V> Cache<K, V> buildImpl(
+      PersistentCacheDef<K, V> in, long diskLimit, CacheBackend backend);
+
+  protected abstract <K, V> LoadingCache<K, V> buildImpl(
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long diskLimit, CacheBackend backend);
+
+  @Override
+  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in, CacheBackend backend) {
+    long limit = getDiskLimit(in);
+
+    if (isInMemoryCache(limit)) {
+      return memCacheFactory.build(in, backend);
+    }
+
+    return buildImpl(in, limit, backend);
+  }
+
+  @Override
+  public <K, V> LoadingCache<K, V> build(
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, CacheBackend backend) {
+    long limit = getDiskLimit(in);
+
+    if (isInMemoryCache(limit)) {
+      return memCacheFactory.build(in, loader, backend);
+    }
+
+    return buildImpl(in, loader, limit, backend);
+  }
+
+  private <K, V> long getDiskLimit(PersistentCacheDef<K, V> in) {
+    return config.getLong("cache", in.configKey(), "diskLimit", in.diskLimit());
+  }
+
+  private <K, V> boolean isInMemoryCache(long diskLimit) {
+    return !diskEnabled || diskLimit <= 0;
+  }
+
+  private static Path getCacheDir(SitePaths site, String name) {
+    if (name == null) {
+      return null;
+    }
+    Path loc = site.resolve(name);
+    if (!Files.exists(loc)) {
+      try {
+        Files.createDirectories(loc);
+      } catch (IOException e) {
+        logger.atWarning().log("Can't create disk cache: %s", loc.toAbsolutePath());
+        return null;
+      }
+    }
+    if (!Files.isWritable(loc)) {
+      logger.atWarning().log("Can't write to disk cache: %s", loc.toAbsolutePath());
+      return null;
+    }
+    logger.atInfo().log("Enabling disk cache %s", loc.toAbsolutePath());
+    return loc;
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 82615a4..16d62b3 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -23,8 +23,8 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.MemoryCacheFactory;
+import com.google.gerrit.server.cache.PersistentCacheBaseFactory;
 import com.google.gerrit.server.cache.PersistentCacheDef;
-import com.google.gerrit.server.cache.PersistentCacheFactory;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -34,9 +34,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
@@ -52,12 +49,9 @@
  * is unset, it will fall back to in-memory caches.
  */
 @Singleton
-class H2CacheFactory implements PersistentCacheFactory, LifecycleListener {
+class H2CacheFactory extends PersistentCacheBaseFactory implements LifecycleListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final MemoryCacheFactory memCacheFactory;
-  private final Config config;
-  private final Path cacheDir;
   private final List<H2CacheImpl<?, ?>> caches;
   private final DynamicMap<Cache<?, ?>> cacheMap;
   private final ExecutorService executor;
@@ -71,15 +65,13 @@
       @GerritServerConfig Config cfg,
       SitePaths site,
       DynamicMap<Cache<?, ?>> cacheMap) {
-    this.memCacheFactory = memCacheFactory;
-    config = cfg;
-    cacheDir = getCacheDir(site, cfg.getString("cache", null, "directory"));
+    super(memCacheFactory, cfg, site);
     h2CacheSize = cfg.getLong("cache", null, "h2CacheSize", -1);
     h2AutoServer = cfg.getBoolean("cache", null, "h2AutoServer", false);
     caches = new LinkedList<>();
     this.cacheMap = cacheMap;
 
-    if (cacheDir != null) {
+    if (diskEnabled) {
       executor =
           new LoggingContextAwareExecutorService(
               Executors.newFixedThreadPool(
@@ -98,27 +90,6 @@
     }
   }
 
-  private static Path getCacheDir(SitePaths site, String name) {
-    if (name == null) {
-      return null;
-    }
-    Path loc = site.resolve(name);
-    if (!Files.exists(loc)) {
-      try {
-        Files.createDirectories(loc);
-      } catch (IOException e) {
-        logger.atWarning().log("Can't create disk cache: %s", loc.toAbsolutePath());
-        return null;
-      }
-    }
-    if (!Files.isWritable(loc)) {
-      logger.atWarning().log("Can't write to disk cache: %s", loc.toAbsolutePath());
-      return null;
-    }
-    logger.atInfo().log("Enabling disk cache %s", loc.toAbsolutePath());
-    return loc;
-  }
-
   @Override
   public void start() {
     if (executor != null) {
@@ -161,13 +132,8 @@
 
   @SuppressWarnings({"unchecked"})
   @Override
-  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in, CacheBackend backend) {
-    long limit = config.getLong("cache", in.configKey(), "diskLimit", in.diskLimit());
-
-    if (cacheDir == null || limit <= 0) {
-      return memCacheFactory.build(in, backend);
-    }
-
+  public <K, V> Cache<K, V> buildImpl(
+      PersistentCacheDef<K, V> in, long limit, CacheBackend backend) {
     H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
     SqlStore<K, V> store = newSqlStore(def, limit);
     H2CacheImpl<K, V> cache =
@@ -184,14 +150,8 @@
 
   @SuppressWarnings("unchecked")
   @Override
-  public <K, V> LoadingCache<K, V> build(
-      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, CacheBackend backend) {
-    long limit = config.getLong("cache", in.configKey(), "diskLimit", in.diskLimit());
-
-    if (cacheDir == null || limit <= 0) {
-      return memCacheFactory.build(in, loader, backend);
-    }
-
+  public <K, V> LoadingCache<K, V> buildImpl(
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long limit, CacheBackend backend) {
     H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
     SqlStore<K, V> store = newSqlStore(def, limit);
     Cache<K, ValueHolder<V>> mem =
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/BUILD b/java/com/google/gerrit/server/cache/serialize/entities/BUILD
index cb8c4ae..55080e8 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/BUILD
+++ b/java/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -5,12 +5,8 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/git",
-        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/server/cache/serialize",
         "//lib:guava",
         "//lib:jgit",
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java
new file mode 100644
index 0000000..7449917
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java
@@ -0,0 +1,82 @@
+// 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.collect.ImmutableSet.toImmutableSet;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import java.sql.Timestamp;
+
+/** Helper to (de)serialize values for caches. */
+public class InternalGroupSerializer {
+  public static InternalGroup deserialize(Cache.InternalGroupProto proto) {
+    InternalGroup.Builder builder =
+        InternalGroup.builder()
+            .setId(AccountGroup.id(proto.getId()))
+            .setNameKey(AccountGroup.nameKey(proto.getName()))
+            .setOwnerGroupUUID(AccountGroup.uuid(proto.getOwnerGroupUuid()))
+            .setVisibleToAll(proto.getIsVisibleToAll())
+            .setGroupUUID(AccountGroup.uuid(proto.getGroupUuid()))
+            .setCreatedOn(new Timestamp(proto.getCreatedOn()))
+            .setMembers(
+                proto.getMembersIdsList().stream()
+                    .map(a -> Account.id(a))
+                    .collect(toImmutableSet()))
+            .setSubgroups(
+                proto.getSubgroupUuidsList().stream()
+                    .map(s -> AccountGroup.uuid(s))
+                    .collect(toImmutableSet()));
+
+    if (!proto.getDescription().isEmpty()) {
+      builder.setDescription(proto.getDescription());
+    }
+
+    if (!proto.getRefState().isEmpty()) {
+      builder.setRefState(ObjectIdConverter.create().fromByteString(proto.getRefState()));
+    }
+
+    return builder.build();
+  }
+
+  public static Cache.InternalGroupProto serialize(InternalGroup autoValue) {
+    Cache.InternalGroupProto.Builder builder =
+        Cache.InternalGroupProto.newBuilder()
+            .setId(autoValue.getId().get())
+            .setName(autoValue.getName())
+            .setOwnerGroupUuid(autoValue.getOwnerGroupUUID().get())
+            .setIsVisibleToAll(autoValue.isVisibleToAll())
+            .setGroupUuid(autoValue.getGroupUUID().get())
+            .setCreatedOn(autoValue.getCreatedOn().getTime());
+
+    autoValue.getMembers().stream().forEach(m -> builder.addMembersIds(m.get()));
+    autoValue.getSubgroups().stream().forEach(s -> builder.addSubgroupUuids(s.get()));
+
+    if (autoValue.getDescription() != null) {
+      builder.setDescription(autoValue.getDescription());
+    }
+
+    if (autoValue.getRefState() != null) {
+      builder.setRefState(ObjectIdConverter.create().toByteString(autoValue.getRefState()));
+    }
+
+    return builder.build();
+  }
+
+  private InternalGroupSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
index 291db4a..4627cdb 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
@@ -42,6 +42,8 @@
         .setCopyAnyScore(proto.getCopyAnyScore())
         .setCopyMinScore(proto.getCopyMinScore())
         .setCopyMaxScore(proto.getCopyMaxScore())
+        .setCopyAllScoresIfListOfFilesDidNotChange(
+            proto.getCopyAllScoresIfListOfFilesDidNotChange())
         .setCopyAllScoresOnMergeFirstParentUpdate(proto.getCopyAllScoresOnMergeFirstParentUpdate())
         .setCopyAllScoresOnTrivialRebase(proto.getCopyAllScoresOnTrivialRebase())
         .setCopyAllScoresIfNoCodeChange(proto.getCopyAllScoresIfNoCodeChange())
@@ -68,6 +70,8 @@
         .setCopyAnyScore(autoValue.isCopyAnyScore())
         .setCopyMinScore(autoValue.isCopyMinScore())
         .setCopyMaxScore(autoValue.isCopyMaxScore())
+        .setCopyAllScoresIfListOfFilesDidNotChange(
+            autoValue.isCopyAllScoresIfListOfFilesDidNotChange())
         .setCopyAllScoresOnMergeFirstParentUpdate(
             autoValue.isCopyAllScoresOnMergeFirstParentUpdate())
         .setCopyAllScoresOnTrivialRebase(autoValue.isCopyAllScoresOnTrivialRebase())
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index 6f28dad..54ebf40 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -89,9 +89,9 @@
     return Lists.newArrayList(visitorSet);
   }
 
-  void addChangeActions(ChangeInfo to, ChangeNotes notes) {
+  void addChangeActions(ChangeInfo to, ChangeData changeData) {
     List<ActionVisitor> visitors = visitors();
-    to.actions = toActionMap(notes, visitors, copy(visitors, to));
+    to.actions = toActionMap(changeData, visitors, copy(visitors, to));
   }
 
   void addRevisionActions(@Nullable ChangeInfo changeInfo, RevisionInfo to, RevisionResource rsrc) {
@@ -167,7 +167,7 @@
   }
 
   private Map<String, ActionInfo> toActionMap(
-      ChangeNotes notes, List<ActionVisitor> visitors, ChangeInfo changeInfo) {
+      ChangeData changeData, List<ActionVisitor> visitors, ChangeInfo changeInfo) {
     CurrentUser user = userProvider.get();
     Map<String, ActionInfo> out = new LinkedHashMap<>();
     if (!user.isIdentifiedUser()) {
@@ -175,12 +175,12 @@
     }
 
     Iterable<UiAction.Description> descs =
-        uiActions.from(changeViews, changeResourceFactory.create(notes, user));
+        uiActions.from(changeViews, changeResourceFactory.create(changeData, user));
 
     // The followup action is a client-side only operation that does not
     // have a server side handler. It must be manually registered into the
     // resulting action map.
-    if (!notes.getChange().isAbandoned()) {
+    if (!changeData.change().isAbandoned()) {
       UiAction.Description descr = new UiAction.Description();
       PrivateInternals_UiActionDescription.setId(descr, "followup");
       PrivateInternals_UiActionDescription.setMethod(descr, "POST");
diff --git a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java b/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
deleted file mode 100644
index 663d7aa..0000000
--- a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.server.DynamicOptions.BeanProvider;
-import com.google.gerrit.server.query.change.ChangeData;
-
-/**
- * Interface for plugins to provide additional fields in {@link
- * com.google.gerrit.extensions.common.ChangeInfo ChangeInfo}.
- *
- * <p>Register a {@code ChangeAttributeFactory} in a plugin {@code Module} like this:
- *
- * <pre>
- * DynamicSet.bind(binder(), ChangeAttributeFactory.class).to(YourClass.class);
- * </pre>
- *
- * <p>See the <a
- * href="https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#query_attributes">plugin
- * developer documentation for more details and examples.
- */
-@Deprecated
-public interface ChangeAttributeFactory {
-
-  /**
-   * Create a plugin-provided info field.
-   *
-   * <p>Typically, implementations will subclass {@code PluginDefinedInfo} to add additional fields.
-   *
-   * @param cd change.
-   * @param beanProvider provider of {@code DynamicBean}s, which may be used for reading options.
-   * @param plugin plugin name.
-   * @return the plugin's special change info.
-   */
-  PluginDefinedInfo create(ChangeData cd, BeanProvider beanProvider, String plugin);
-}
diff --git a/java/com/google/gerrit/server/change/ChangeETagComputation.java b/java/com/google/gerrit/server/change/ChangeETagComputation.java
index a5b7d49..2fd5755 100644
--- a/java/com/google/gerrit/server/change/ChangeETagComputation.java
+++ b/java/com/google/gerrit/server/change/ChangeETagComputation.java
@@ -26,7 +26,7 @@
  * <ul>
  *   <li>providing plugin defined attributes to {@link
  *       com.google.gerrit.extensions.common.ChangeInfo#plugins} (see {@link
- *       ChangeAttributeFactory})
+ *       ChangePluginDefinedInfoFactory})
  *   <li>implementing a {@link com.google.gerrit.server.rules.SubmitRule} which affects the
  *       computation of {@link com.google.gerrit.extensions.common.ChangeInfo#submittable}
  * </ul>
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 6091091..fb027bd 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -25,6 +25,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
@@ -63,6 +64,7 @@
 import com.google.gerrit.server.mail.send.CreateChangeSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -113,6 +115,7 @@
   private final ReviewerAdder reviewerAdder;
   private final MessageIdGenerator messageIdGenerator;
   private final DynamicItem<UrlFormatter> urlFormatter;
+  private final AutoMerger autoMerger;
 
   private final Change.Id changeId;
   private final PatchSet.Id psId;
@@ -163,6 +166,7 @@
       ReviewerAdder reviewerAdder,
       MessageIdGenerator messageIdGenerator,
       DynamicItem<UrlFormatter> urlFormatter,
+      AutoMerger autoMerger,
       @Assisted Change.Id changeId,
       @Assisted ObjectId commitId,
       @Assisted String refName) {
@@ -180,6 +184,7 @@
     this.reviewerAdder = reviewerAdder;
     this.messageIdGenerator = messageIdGenerator;
     this.urlFormatter = urlFormatter;
+    this.autoMerger = autoMerger;
 
     this.changeId = changeId;
     this.psId = PatchSet.id(changeId, INITIAL_PATCH_SET_ID);
@@ -377,6 +382,15 @@
       return;
     }
     ctx.addRefUpdate(cmd);
+    Optional<ReceiveCommand> autoMerge =
+        autoMerger.createAutoMergeCommitIfNecessary(
+            ctx.getRepoView(),
+            ctx.getRevWalk(),
+            ctx.getInserter(),
+            ctx.getRevWalk().parseCommit(commitId));
+    if (autoMerge.isPresent()) {
+      ctx.addRefUpdate(autoMerge.get());
+    }
   }
 
   @Override
@@ -546,6 +560,7 @@
               cmd,
               projectState.getProject(),
               change.getDest().branch(),
+              ImmutableListMultimap.of(),
               ctx.getRepoView().getConfig(),
               ctx.getRevWalk().getObjectReader(),
               commitId,
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 7b2663a..029f231 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -47,16 +47,18 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRecord.Status;
-import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.FixInput;
@@ -68,13 +70,14 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
@@ -112,11 +115,14 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * Produces {@link ChangeInfo} (which is serialized to JSON afterwards) from {@link ChangeData}.
@@ -158,17 +164,12 @@
     }
 
     public ChangeJson create(Iterable<ListChangesOption> options) {
-      return factory.create(options, Optional.empty(), Optional.empty());
+      return factory.create(options, Optional.empty());
     }
 
     public ChangeJson create(
-        Iterable<ListChangesOption> options,
-        PluginDefinedAttributesFactory pluginDefinedAttributesFactory,
-        PluginDefinedInfosFactory pluginDefinedInfosFactory) {
-      return factory.create(
-          options,
-          Optional.of(pluginDefinedAttributesFactory),
-          Optional.of(pluginDefinedInfosFactory));
+        Iterable<ListChangesOption> options, PluginDefinedInfosFactory pluginDefinedInfosFactory) {
+      return factory.create(options, Optional.of(pluginDefinedInfosFactory));
     }
 
     public ChangeJson create(ListChangesOption first, ListChangesOption... rest) {
@@ -179,7 +180,6 @@
   public interface AssistedFactory {
     ChangeJson create(
         Iterable<ListChangesOption> options,
-        Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory,
         Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory);
   }
 
@@ -226,7 +226,6 @@
   private final TrackingFooters trackingFooters;
   private final Metrics metrics;
   private final RevisionJson revisionJson;
-  private final Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory;
   private final Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory;
   private final boolean includeMergeable;
   private final boolean lazyLoad;
@@ -244,14 +243,13 @@
       Provider<ConsistencyChecker> checkerProvider,
       ActionJson actionJson,
       ChangeNotes.Factory notesFactory,
-      LabelsJson.Factory labelsJsonFactory,
+      LabelsJson labelsJson,
       RemoveReviewerControl removeReviewerControl,
       TrackingFooters trackingFooters,
       Metrics metrics,
       RevisionJson.Factory revisionJsonFactory,
       @GerritServerConfig Config cfg,
       @Assisted Iterable<ListChangesOption> options,
-      @Assisted Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory,
       @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
     this.userProvider = user;
     this.changeDataFactory = cdf;
@@ -261,7 +259,7 @@
     this.checkerProvider = checkerProvider;
     this.actionJson = actionJson;
     this.notesFactory = notesFactory;
-    this.labelsJson = labelsJsonFactory.create(options);
+    this.labelsJson = labelsJson;
     this.removeReviewerControl = removeReviewerControl;
     this.trackingFooters = trackingFooters;
     this.metrics = metrics;
@@ -269,7 +267,6 @@
     this.options = Sets.immutableEnumSet(options);
     this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
     this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
-    this.pluginDefinedAttributesFactory = pluginDefinedAttributesFactory;
     this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
 
     logger.atFine().log("options = %s", options);
@@ -288,6 +285,11 @@
     return format(changeDataFactory.create(change));
   }
 
+  public ChangeInfo format(Change change, @Nullable ObjectId metaRevId) {
+    ChangeNotes notes = notesFactory.createChecked(change.getProject(), change.getId(), metaRevId);
+    return format(changeDataFactory.create(notes));
+  }
+
   public ChangeInfo format(ChangeData cd) {
     return format(cd, Optional.empty(), true, getPluginInfos(cd));
   }
@@ -330,9 +332,13 @@
   }
 
   public ChangeInfo format(Project.NameKey project, Change.Id id) {
+    return format(project, id, null);
+  }
+
+  public ChangeInfo format(Project.NameKey project, Change.Id id, @Nullable ObjectId metaRevId) {
     ChangeNotes notes;
     try {
-      notes = notesFactory.createChecked(project, id);
+      notes = notesFactory.createChecked(project, id, metaRevId);
     } catch (StorageException e) {
       if (!has(CHECK)) {
         throw e;
@@ -343,21 +349,22 @@
     return format(cd, Optional.empty(), true, getPluginInfos(cd));
   }
 
-  private static Collection<SubmitRequirementInfo> requirementsFor(ChangeData cd) {
-    Collection<SubmitRequirementInfo> reqInfos = new ArrayList<>();
+  private static Collection<LegacySubmitRequirementInfo> requirementsFor(ChangeData cd) {
+    Collection<LegacySubmitRequirementInfo> reqInfos = new ArrayList<>();
     for (SubmitRecord submitRecord : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
       if (submitRecord.requirements == null) {
         continue;
       }
-      for (SubmitRequirement requirement : submitRecord.requirements) {
+      for (LegacySubmitRequirement requirement : submitRecord.requirements) {
         reqInfos.add(requirementToInfo(requirement, submitRecord.status));
       }
     }
     return reqInfos;
   }
 
-  private static SubmitRequirementInfo requirementToInfo(SubmitRequirement req, Status status) {
-    return new SubmitRequirementInfo(status.name(), req.fallbackText(), req.type());
+  private static LegacySubmitRequirementInfo requirementToInfo(
+      LegacySubmitRequirement req, Status status) {
+    return new LegacySubmitRequirementInfo(status.name(), req.fallbackText(), req.type());
   }
 
   private static void finish(ChangeInfo info) {
@@ -399,6 +406,10 @@
 
   private void ensureLoaded(Iterable<ChangeData> all) {
     if (lazyLoad) {
+      for (ChangeData cd : all) {
+        // Mark all ChangeDatas as coming from the index, but allow backfilling data from NoteDb
+        cd.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
+      }
       ChangeData.ensureChangeLoaded(all);
       if (has(ALL_REVISIONS)) {
         ChangeData.ensureAllPatchSetsLoaded(all);
@@ -411,7 +422,8 @@
       ChangeData.ensureCurrentApprovalsLoaded(all);
     } else {
       for (ChangeData cd : all) {
-        cd.setLazyLoad(false);
+        // Mark all ChangeDatas as coming from the index. Disallow using NoteDb
+        cd.setStorageConstraint(ChangeData.StorageConstraint.INDEX_ONLY);
       }
     }
   }
@@ -577,6 +589,15 @@
     out.totalCommentCount = cd.totalCommentCount();
     out.unresolvedCommentCount = cd.unresolvedCommentCount();
 
+    if (cd.getRefStates() != null) {
+      String metaName = RefNames.changeMetaRef(cd.getId());
+      Optional<RefState> metaState =
+          cd.getRefStates().values().stream().filter(r -> r.ref().equals(metaName)).findAny();
+
+      // metaState should always be there, but it doesn't hurt to be extra careful.
+      metaState.ifPresent(rs -> out.metaRevId = rs.id().getName());
+    }
+
     if (user.isIdentifiedUser()) {
       Collection<String> stars = cd.stars(user.getAccountId());
       out.starred = stars.contains(StarredChangesUtil.DEFAULT_LABEL) ? true : null;
@@ -611,17 +632,9 @@
     }
 
     setSubmitter(cd, out);
-    if (pluginDefinedAttributesFactory.isPresent()) {
-      out.plugins = pluginDefinedAttributesFactory.get().create(cd);
-    }
 
     if (!pluginInfos.isEmpty()) {
-      if (out.plugins == null) {
-        out.plugins = pluginInfos;
-      } else {
-        out.plugins = new ArrayList<>(out.plugins);
-        out.plugins.addAll(pluginInfos);
-      }
+      out.plugins = pluginInfos;
     }
     out.revertOf = cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null;
     out.submissionId = cd.change().getSubmissionId();
@@ -665,7 +678,7 @@
     }
 
     if (has(CURRENT_ACTIONS) || has(CHANGE_ACTIONS)) {
-      actionJson.addChangeActions(out, cd.notes());
+      actionJson.addChangeActions(out, cd);
     }
 
     if (has(TRACKING_IDS)) {
@@ -749,7 +762,14 @@
     // removed.
     Collection<LabelInfo> labels = out.labels.values();
     Set<Account.Id> fixed = Sets.newHashSetWithExpectedSize(labels.size());
-    Set<Account.Id> removable = Sets.newHashSetWithExpectedSize(labels.size());
+    Set<Account.Id> removable = new HashSet<>();
+
+    // Add all reviewers, which will later be removed if they are in the "fixed" set.
+    removable.addAll(
+        out.reviewers.getOrDefault(ReviewerState.REVIEWER, Collections.emptySet()).stream()
+            .filter(a -> a._accountId != null)
+            .map(a -> Account.id(a._accountId))
+            .collect(Collectors.toSet()));
 
     // Check if the user has the permission to remove a reviewer. This means we can bypass the
     // testRemoveReviewer check for a specific reviewer in the loop saving potentially many
@@ -766,11 +786,9 @@
       for (ApprovalInfo ai : label.all) {
         Account.Id id = Account.id(ai._accountId);
 
-        if (canRemoveAnyReviewer
-            || removeReviewerControl.testRemoveReviewer(
+        if (!canRemoveAnyReviewer
+            && !removeReviewerControl.testRemoveReviewer(
                 cd, userProvider.get(), id, MoreObjects.firstNonNull(ai.value, 0))) {
-          removable.add(id);
-        } else {
           fixed.add(id);
         }
       }
diff --git a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java b/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
deleted file mode 100644
index 0db4cea..0000000
--- a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gson.JsonDeserializationContext;
-import com.google.gson.JsonDeserializer;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParseException;
-import com.google.gson.JsonSerializationContext;
-import com.google.gson.JsonSerializer;
-import java.lang.reflect.Type;
-
-/**
- * Adapter that serializes {@link com.google.gerrit.entities.Change.Key}'s {@code key} field as
- * {@code id}, for backwards compatibility in stream-events.
- */
-// TODO(dborowitz): auto-value-gson should support this directly using @SerializedName on the
-// AutoValue method.
-public class ChangeKeyAdapter implements JsonSerializer<Change.Key>, JsonDeserializer<Change.Key> {
-  @Override
-  public JsonElement serialize(Change.Key src, Type typeOfSrc, JsonSerializationContext context) {
-    JsonObject obj = new JsonObject();
-    obj.addProperty("id", src.get());
-    return obj;
-  }
-
-  @Override
-  public Change.Key deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
-      throws JsonParseException {
-    JsonElement keyJson = json.getAsJsonObject().get("id");
-    if (keyJson == null || !keyJson.isJsonPrimitive() || !keyJson.getAsJsonPrimitive().isString()) {
-      throw new JsonParseException("Key is not a string: " + keyJson);
-    }
-    String key = keyJson.getAsJsonPrimitive().getAsString();
-    return Change.key(key);
-  }
-}
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 5a1798d..3729b59 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -44,9 +44,10 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.inject.Inject;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.util.HashSet;
 import java.util.Optional;
 import java.util.Set;
@@ -66,6 +67,8 @@
 
   public interface Factory {
     ChangeResource create(ChangeNotes notes, CurrentUser user);
+
+    ChangeResource create(ChangeData changeData, CurrentUser user);
   }
 
   private static final String ZERO_ID_STRING = ObjectId.zeroId().name();
@@ -77,10 +80,10 @@
   private final StarredChangesUtil starredChangesUtil;
   private final ProjectCache projectCache;
   private final PluginSetContext<ChangeETagComputation> changeETagComputation;
-  private final ChangeNotes notes;
+  private final ChangeData changeData;
   private final CurrentUser user;
 
-  @Inject
+  @AssistedInject
   ChangeResource(
       AccountCache accountCache,
       ApprovalsUtil approvalUtil,
@@ -89,6 +92,7 @@
       StarredChangesUtil starredChangesUtil,
       ProjectCache projectCache,
       PluginSetContext<ChangeETagComputation> changeETagComputation,
+      ChangeData.Factory changeDataFactory,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user) {
     this.accountCache = accountCache;
@@ -98,12 +102,34 @@
     this.starredChangesUtil = starredChangesUtil;
     this.projectCache = projectCache;
     this.changeETagComputation = changeETagComputation;
-    this.notes = notes;
+    this.changeData = changeDataFactory.create(notes);
+    this.user = user;
+  }
+
+  @AssistedInject
+  ChangeResource(
+      AccountCache accountCache,
+      ApprovalsUtil approvalUtil,
+      PatchSetUtil patchSetUtil,
+      PermissionBackend permissionBackend,
+      StarredChangesUtil starredChangesUtil,
+      ProjectCache projectCache,
+      PluginSetContext<ChangeETagComputation> changeETagComputation,
+      @Assisted ChangeData changeData,
+      @Assisted CurrentUser user) {
+    this.accountCache = accountCache;
+    this.approvalUtil = approvalUtil;
+    this.patchSetUtil = patchSetUtil;
+    this.permissionBackend = permissionBackend;
+    this.starredChangesUtil = starredChangesUtil;
+    this.projectCache = projectCache;
+    this.changeETagComputation = changeETagComputation;
+    this.changeData = changeData;
     this.user = user;
   }
 
   public PermissionBackend.ForChange permissions() {
-    return permissionBackend.user(user).change(notes);
+    return permissionBackend.user(user).change(getNotes());
   }
 
   public CurrentUser getUser() {
@@ -111,7 +137,7 @@
   }
 
   public Change.Id getId() {
-    return notes.getChangeId();
+    return changeData.getId();
   }
 
   /** @return true if {@link #getUser()} is the change's owner. */
@@ -121,7 +147,7 @@
   }
 
   public Change getChange() {
-    return notes.getChange();
+    return changeData.change();
   }
 
   public Project.NameKey getProject() {
@@ -129,7 +155,11 @@
   }
 
   public ChangeNotes getNotes() {
-    return notes;
+    return changeData.notes();
+  }
+
+  public ChangeData getChangeData() {
+    return changeData;
   }
 
   // This includes all information relevant for ETag computation
@@ -153,7 +183,7 @@
       accounts.add(getChange().getAssignee());
     }
     try {
-      patchSetUtil.byChange(notes).stream().map(PatchSet::uploader).forEach(accounts::add);
+      patchSetUtil.byChange(getNotes()).stream().map(PatchSet::uploader).forEach(accounts::add);
 
       // It's intentional to include the states for *all* reviewers into the ETag computation.
       // We need the states of all current reviewers and CCs because they are part of ChangeInfo.
@@ -162,7 +192,7 @@
       // set of accounts that posted a message is too expensive. However everyone who posts a
       // message is automatically added as reviewer. Hence if we include removed reviewers we can
       // be sure that we have all accounts that posted messages on the change.
-      accounts.addAll(approvalUtil.getReviewers(notes).all());
+      accounts.addAll(approvalUtil.getReviewers(getNotes()).all());
     } catch (StorageException e) {
       // This ETag will be invalidated if it loads next time.
     }
@@ -178,7 +208,7 @@
 
     ObjectId noteId;
     try {
-      noteId = notes.loadRevision();
+      noteId = getNotes().loadRevision();
     } catch (StorageException e) {
       noteId = null; // This ETag will be invalidated if it loads next time.
     }
@@ -194,7 +224,7 @@
 
     changeETagComputation.runEach(
         c -> {
-          String pluginETag = c.getETag(notes.getProjectName(), notes.getChangeId());
+          String pluginETag = c.getETag(changeData.project(), changeData.getId());
           if (pluginETag != null) {
             h.putString(pluginETag, UTF_8);
           }
@@ -207,8 +237,8 @@
         TraceContext.newTimer(
             "Compute change ETag",
             Metadata.builder()
-                .changeId(notes.getChangeId().get())
-                .projectName(notes.getProjectName().get())
+                .changeId(changeData.getId().get())
+                .projectName(changeData.project().get())
                 .build())) {
       Hasher h = Hashing.murmur3_128().newHasher();
       if (user.isIdentifiedUser()) {
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 07cb04f..bf00d27 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.RemoveReviewerControl;
@@ -132,9 +133,15 @@
     for (LabelType lt : labelTypes.getLabelTypes()) {
       newApprovals.put(lt.getName(), (short) 0);
     }
-
+    String ccOrReviewer =
+        approvalsUtil
+                .getReviewers(ctx.getNotes())
+                .byState(ReviewerStateInternal.CC)
+                .contains(reviewerId)
+            ? "cc"
+            : "reviewer";
     StringBuilder msg = new StringBuilder();
-    msg.append("Removed reviewer " + reviewer.account().fullName());
+    msg.append(String.format("Removed %s %s", ccOrReviewer, reviewer.account().fullName()));
     StringBuilder removedVotesMsg = new StringBuilder();
     removedVotesMsg.append(" with the following votes:\n\n");
     boolean votesRemoved = false;
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
index aca4fb0..ad6f9c7 100644
--- a/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2013 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -16,108 +16,67 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListEntry;
-import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.util.Map;
-import java.util.TreeMap;
-import java.util.concurrent.ExecutionException;
-import org.eclipse.jgit.errors.NoMergeBaseException;
 import org.eclipse.jgit.lib.ObjectId;
 
-@Singleton
-public class FileInfoJson {
-  private final PatchListCache patchListCache;
+/** Compute and return the list of modified files between two commits. */
+public interface FileInfoJson {
 
-  @Inject
-  FileInfoJson(PatchListCache patchListCache) {
-    this.patchListCache = patchListCache;
-  }
-
-  public Map<String, FileInfo> toFileInfoMap(Change change, PatchSet patchSet)
+  /**
+   * Computes the list of modified files for a given change and patchset against the parent commit.
+   *
+   * @param change a Gerrit change.
+   * @param patchSet a single revision of the change.
+   * @return a mapping of the file paths to their related diff information.
+   */
+  default Map<String, FileInfo> getFileInfoMap(Change change, PatchSet patchSet)
       throws ResourceConflictException, PatchListNotAvailableException {
-    return toFileInfoMap(change, patchSet.commitId(), null);
+    return getFileInfoMap(change, patchSet.commitId(), null);
   }
 
-  public Map<String, FileInfo> toFileInfoMap(
-      Change change, ObjectId objectId, @Nullable PatchSet base)
+  /**
+   * Computes the list of modified files for a given change and patchset against its parent. For
+   * merge commits, callers can use 0, 1, 2, etc... to choose a specific parent. The first parent is
+   * 0.
+   *
+   * @param change a Gerrit change.
+   * @param objectId a commit SHA-1 identifying a patchset commit.
+   * @param parentNum an integer identifying the parent number used for comparison.
+   * @return a mapping of the file paths to their related diff information.
+   */
+  default Map<String, FileInfo> getFileInfoMap(Change change, ObjectId objectId, int parentNum)
       throws ResourceConflictException, PatchListNotAvailableException {
-    ObjectId a = base != null ? base.commitId() : null;
-    return toFileInfoMap(change, PatchListKey.againstCommit(a, objectId, Whitespace.IGNORE_NONE));
+    return getFileInfoMap(change.getProject(), objectId, parentNum);
   }
 
-  public Map<String, FileInfo> toFileInfoMap(Change change, ObjectId objectId, int parent)
-      throws ResourceConflictException, PatchListNotAvailableException {
-    return toFileInfoMap(
-        change, PatchListKey.againstParentNum(parent + 1, objectId, Whitespace.IGNORE_NONE));
-  }
+  /**
+   * Computes the list of modified files for a given change and patchset identified by its {@code
+   * objectId} against a specified base patchset.
+   *
+   * @param change a Gerrit change.
+   * @param objectId a commit SHA-1 identifying a patchset commit.
+   * @param base a base patchset to compare the commit identified by {@code objectId} against.
+   * @return a mapping of the file paths to their related diff information.
+   */
+  Map<String, FileInfo> getFileInfoMap(Change change, ObjectId objectId, @Nullable PatchSet base)
+      throws ResourceConflictException, PatchListNotAvailableException;
 
-  private Map<String, FileInfo> toFileInfoMap(Change change, PatchListKey key)
-      throws ResourceConflictException, PatchListNotAvailableException {
-    return toFileInfoMap(change.getProject(), key);
-  }
-
-  public Map<String, FileInfo> toFileInfoMap(Project.NameKey project, PatchListKey key)
-      throws ResourceConflictException, PatchListNotAvailableException {
-    PatchList list;
-    try {
-      list = patchListCache.get(key, project);
-    } catch (PatchListNotAvailableException e) {
-      Throwable cause = e.getCause();
-      if (cause instanceof ExecutionException) {
-        cause = cause.getCause();
-      }
-      if (cause instanceof NoMergeBaseException) {
-        throw new ResourceConflictException(
-            String.format("Cannot create auto merge commit: %s", e.getMessage()), e);
-      }
-      throw e;
-    }
-
-    Map<String, FileInfo> files = new TreeMap<>();
-    for (PatchListEntry e : list.getPatches()) {
-      FileInfo d = new FileInfo();
-      d.status =
-          e.getChangeType() != Patch.ChangeType.MODIFIED ? e.getChangeType().getCode() : null;
-      d.oldPath = e.getOldName();
-      d.sizeDelta = e.getSizeDelta();
-      d.size = e.getSize();
-      if (e.getPatchType() == Patch.PatchType.BINARY) {
-        d.binary = true;
-      } else {
-        d.linesInserted = e.getInsertions() > 0 ? e.getInsertions() : null;
-        d.linesDeleted = e.getDeletions() > 0 ? e.getDeletions() : null;
-      }
-
-      FileInfo o = files.put(e.getNewName(), d);
-      if (o != null) {
-        // This should only happen on a delete-add break created by JGit
-        // when the file was rewritten and too little content survived. Write
-        // a single record with data from both sides.
-        d.status = Patch.ChangeType.REWRITE.getCode();
-        d.sizeDelta = o.sizeDelta;
-        d.size = o.size;
-        if (o.binary != null && o.binary) {
-          d.binary = true;
-        }
-        if (o.linesInserted != null) {
-          d.linesInserted = o.linesInserted;
-        }
-        if (o.linesDeleted != null) {
-          d.linesDeleted = o.linesDeleted;
-        }
-      }
-    }
-    return files;
-  }
+  /**
+   * Computes the list of modified files for a given project and commit against its parent. For
+   * merge commits, callers can use 0, 1, 2, etc... to choose a specific parent. The first parent is
+   * 0. A value of -1 for parent can be passed to use the default base commit, which is the only
+   * parent for commits having only one parent, or the auto-merge otherwise.
+   *
+   * @param project a project identifying a repository.
+   * @param objectId a commit SHA-1 identifying a patchset commit.
+   * @param parentNum an integer identifying the parent number used for comparison.
+   * @return a mapping of the file paths to their related diff information.
+   */
+  Map<String, FileInfo> getFileInfoMap(Project.NameKey project, ObjectId objectId, int parentNum)
+      throws ResourceConflictException, PatchListNotAvailableException;
 }
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java
new file mode 100644
index 0000000..228d631
--- /dev/null
+++ b/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java
@@ -0,0 +1,160 @@
+// 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/FileInfoJsonModule.java b/java/com/google/gerrit/server/change/FileInfoJsonModule.java
new file mode 100644
index 0000000..de116bb
--- /dev/null
+++ b/java/com/google/gerrit/server/change/FileInfoJsonModule.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.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);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
new file mode 100644
index 0000000..1ca2c93
--- /dev/null
+++ b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.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 com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.FilePathAdapter;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.errors.NoMergeBaseException;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Implementation of {@link FileInfoJson} using the new diff cache {@link DiffOperations}. */
+public class FileInfoJsonNewImpl implements FileInfoJson {
+  private final DiffOperations diffs;
+
+  @Inject
+  FileInfoJsonNewImpl(DiffOperations diffOperations) {
+    this.diffs = diffOperations;
+  }
+
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Change change, ObjectId objectId, @Nullable PatchSet base)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    try {
+      if (base == null) {
+        return asFileInfo(
+            diffs.listModifiedFilesAgainstParent(change.getProject(), objectId, null));
+      }
+      return asFileInfo(diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId));
+    } catch (DiffNotAvailableException e) {
+      convertException(e);
+      return null; // unreachable. handleAndThrow will throw an exception anyway
+    }
+  }
+
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Project.NameKey project, ObjectId objectId, int parent)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    try {
+      Map<String, FileDiffOutput> modifiedFiles =
+          diffs.listModifiedFilesAgainstParent(project, objectId, parent + 1);
+      return asFileInfo(modifiedFiles);
+    } catch (DiffNotAvailableException e) {
+      convertException(e);
+      return null; // unreachable. handleAndThrow will throw an exception anyway
+    }
+  }
+
+  private void convertException(DiffNotAvailableException e)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    Throwable cause = e.getCause();
+    if (cause != null && !(cause instanceof NoMergeBaseException)) {
+      cause = cause.getCause();
+    }
+    if (cause instanceof NoMergeBaseException) {
+      throw new ResourceConflictException(
+          String.format("Cannot create auto merge commit: %s", e.getMessage()), e);
+    }
+    throw new PatchListNotAvailableException(e);
+  }
+
+  private Map<String, FileInfo> asFileInfo(Map<String, FileDiffOutput> fileDiffs) {
+    Map<String, FileInfo> result = new HashMap<>();
+    for (String path : fileDiffs.keySet()) {
+      FileDiffOutput fileDiff = fileDiffs.get(path);
+      FileInfo fileInfo = new FileInfo();
+      fileInfo.status =
+          fileDiff.changeType() != Patch.ChangeType.MODIFIED
+              ? fileDiff.changeType().getCode()
+              : null;
+      fileInfo.oldPath = FilePathAdapter.getOldPath(fileDiff.oldPath(), fileDiff.changeType());
+      fileInfo.sizeDelta = fileDiff.sizeDelta();
+      fileInfo.size = fileDiff.size();
+      if (fileDiff.patchType().get() == Patch.PatchType.BINARY) {
+        fileInfo.binary = true;
+      } else {
+        fileInfo.linesInserted = fileDiff.insertions() > 0 ? fileDiff.insertions() : null;
+        fileInfo.linesDeleted = fileDiff.deletions() > 0 ? fileDiff.deletions() : null;
+      }
+      result.put(path, fileInfo);
+    }
+    return result;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
new file mode 100644
index 0000000..55d162a
--- /dev/null
+++ b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.errors.NoMergeBaseException;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Implementation of {@link FileInfoJson} using the old diff cache {@link PatchListCache}. */
+@Deprecated
+@Singleton
+class FileInfoJsonOldImpl implements FileInfoJson {
+  private final PatchListCache patchListCache;
+
+  @Inject
+  FileInfoJsonOldImpl(PatchListCache patchListCache) {
+    this.patchListCache = patchListCache;
+  }
+
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Change change, ObjectId objectId, @Nullable PatchSet base)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    ObjectId a = base != null ? base.commitId() : null;
+    return toFileInfoMap(change, PatchListKey.againstCommit(a, objectId, Whitespace.IGNORE_NONE));
+  }
+
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Project.NameKey project, ObjectId objectId, int parentNum)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    PatchListKey key =
+        parentNum == -1
+            ? PatchListKey.againstDefaultBase(objectId, Whitespace.IGNORE_NONE)
+            : PatchListKey.againstParentNum(
+                parentNum + 1, objectId, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+    return toFileInfoMap(project, key);
+  }
+
+  private Map<String, FileInfo> toFileInfoMap(Change change, PatchListKey key)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    return toFileInfoMap(change.getProject(), key);
+  }
+
+  Map<String, FileInfo> toFileInfoMap(Project.NameKey project, PatchListKey key)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    PatchList list;
+    try {
+      list = patchListCache.get(key, project);
+    } catch (PatchListNotAvailableException e) {
+      Throwable cause = e.getCause();
+      if (cause instanceof ExecutionException) {
+        cause = cause.getCause();
+      }
+      if (cause instanceof NoMergeBaseException) {
+        throw new ResourceConflictException(
+            String.format("Cannot create auto merge commit: %s", e.getMessage()), e);
+      }
+      throw e;
+    }
+
+    Map<String, FileInfo> files = new TreeMap<>();
+    for (PatchListEntry e : list.getPatches()) {
+      FileInfo fileInfo = new FileInfo();
+      fileInfo.status =
+          e.getChangeType() != Patch.ChangeType.MODIFIED ? e.getChangeType().getCode() : null;
+      fileInfo.oldPath = e.getOldName();
+      fileInfo.sizeDelta = e.getSizeDelta();
+      fileInfo.size = e.getSize();
+      if (e.getPatchType() == Patch.PatchType.BINARY) {
+        fileInfo.binary = true;
+      } else {
+        fileInfo.linesInserted = e.getInsertions() > 0 ? e.getInsertions() : null;
+        fileInfo.linesDeleted = e.getDeletions() > 0 ? e.getDeletions() : null;
+      }
+
+      FileInfo o = files.put(e.getNewName(), fileInfo);
+      if (o != null) {
+        // This should only happen on a delete-add break created by JGit
+        // when the file was rewritten and too little content survived. Write
+        // a single record with data from both sides.
+        fileInfo.status = Patch.ChangeType.REWRITE.getCode();
+        fileInfo.sizeDelta = o.sizeDelta;
+        fileInfo.size = o.size;
+        if (o.binary != null && o.binary) {
+          fileInfo.binary = true;
+        }
+        if (o.linesInserted != null) {
+          fileInfo.linesInserted = o.linesInserted;
+        }
+        if (o.linesDeleted != null) {
+          fileInfo.linesDeleted = o.linesDeleted;
+        }
+      }
+    }
+    return files;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index b1d154c..acff03c 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -20,14 +20,12 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
-import com.google.common.collect.Sets;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
@@ -38,21 +36,18 @@
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.VotingRangeInfo;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
+import com.google.inject.Singleton;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -68,28 +63,15 @@
 /**
  * Produces label-related entities, like {@link LabelInfo}s, which is serialized to JSON afterwards.
  */
+@Singleton
 public class LabelsJson {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public interface Factory {
-    LabelsJson create(Iterable<ListChangesOption> options);
-  }
-
-  private final ApprovalsUtil approvalsUtil;
-  private final ChangeNotes.Factory notesFactory;
   private final PermissionBackend permissionBackend;
-  private final boolean lazyLoad;
 
   @Inject
-  LabelsJson(
-      ApprovalsUtil approvalsUtil,
-      ChangeNotes.Factory notesFactory,
-      PermissionBackend permissionBackend,
-      @Assisted Iterable<ListChangesOption> options) {
-    this.approvalsUtil = approvalsUtil;
-    this.notesFactory = notesFactory;
+  LabelsJson(PermissionBackend permissionBackend) {
     this.permissionBackend = permissionBackend;
-    this.lazyLoad = containsAnyOf(Sets.immutableEnumSet(options), ChangeJson.REQUIRE_LAZY_LOAD);
   }
 
   /**
@@ -171,11 +153,6 @@
     return permitted.asMap();
   }
 
-  private static boolean containsAnyOf(
-      ImmutableSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
-    return !Sets.intersection(toFind, set).isEmpty();
-  }
-
   private static boolean isOnlyZero(Collection<String> values) {
     return values.isEmpty() || (values.size() == 1 && values.contains(" 0"));
   }
@@ -253,14 +230,10 @@
 
   private Map<String, Short> currentLabels(Account.Id accountId, ChangeData cd) {
     Map<String, Short> result = new HashMap<>();
-    for (PatchSetApproval psa :
-        approvalsUtil.byPatchSetUser(
-            lazyLoad ? cd.notes() : notesFactory.createFromIndexedChange(cd.change()),
-            cd.change().currentPatchSetId(),
-            accountId,
-            null,
-            null)) {
-      result.put(psa.label(), psa.value());
+    for (PatchSetApproval psa : cd.currentApprovals()) {
+      if (psa.accountId().equals(accountId)) {
+        result.put(psa.label(), psa.value());
+      }
     }
     return result;
   }
diff --git a/java/com/google/gerrit/server/change/MergeabilityComputationBehavior.java b/java/com/google/gerrit/server/change/MergeabilityComputationBehavior.java
index 26e7a89..87134cb 100644
--- a/java/com/google/gerrit/server/change/MergeabilityComputationBehavior.java
+++ b/java/com/google/gerrit/server/change/MergeabilityComputationBehavior.java
@@ -40,10 +40,7 @@
   /** Returns a {@link MergeabilityComputationBehavior} constructed from a Gerrit server config. */
   public static MergeabilityComputationBehavior fromConfig(Config cfg) {
     return cfg.getEnum(
-        "change",
-        null,
-        "mergeabilityComputationBehavior",
-        MergeabilityComputationBehavior.API_REF_UPDATED_AND_CHANGE_REINDEX);
+        "change", null, "mergeabilityComputationBehavior", MergeabilityComputationBehavior.NEVER);
   }
 
   /** Whether {@code mergeable} should be included in the change API. */
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index ef06ea1..d2bf3fe 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
@@ -43,6 +44,7 @@
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -59,6 +61,7 @@
 import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
@@ -81,6 +84,7 @@
   private final PatchSetUtil psUtil;
   private final WorkInProgressStateChanged wipStateChanged;
   private final MessageIdGenerator messageIdGenerator;
+  private final AutoMerger autoMerger;
 
   // Assisted-injected fields.
   private final PatchSet.Id psId;
@@ -123,6 +127,7 @@
       ProjectCache projectCache,
       WorkInProgressStateChanged wipStateChanged,
       MessageIdGenerator messageIdGenerator,
+      AutoMerger autoMerger,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
       @Assisted ObjectId commitId) {
@@ -137,6 +142,7 @@
     this.projectCache = projectCache;
     this.wipStateChanged = wipStateChanged;
     this.messageIdGenerator = messageIdGenerator;
+    this.autoMerger = autoMerger;
 
     this.origNotes = notes;
     this.psId = psId;
@@ -213,6 +219,15 @@
       throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
     validate(ctx);
     ctx.addRefUpdate(ObjectId.zeroId(), commitId, getPatchSetId().toRefName());
+    Optional<ReceiveCommand> autoMerge =
+        autoMerger.createAutoMergeCommitIfNecessary(
+            ctx.getRepoView(),
+            ctx.getRevWalk(),
+            ctx.getInserter(),
+            ctx.getRevWalk().parseCommit(commitId));
+    if (autoMerge.isPresent()) {
+      ctx.addRefUpdate(autoMerge.get());
+    }
   }
 
   @Override
@@ -342,6 +357,7 @@
                 .orElseThrow(illegalState(origNotes.getProjectName()))
                 .getProject(),
             origNotes.getChange().getDest().branch(),
+            ImmutableListMultimap.of(),
             ctx.getRepoView().getConfig(),
             ctx.getRevWalk().getObjectReader(),
             commitId,
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
index b474dab..db21f11 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
+++ b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
@@ -14,55 +14,22 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.util.concurrent.TimeUnit.MINUTES;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.server.DynamicOptions.BeanProvider;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.util.Collection;
-import java.util.Objects;
 import java.util.stream.Stream;
 
-/** Static helpers for use by {@link PluginDefinedAttributesFactory} implementations. */
+/** Static helpers for use by {@link PluginDefinedInfosFactory} implementations. */
 public class PluginDefinedAttributesFactories {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  @Nullable
-  public static ImmutableList<PluginDefinedInfo> createAll(
-      ChangeData cd,
-      BeanProvider beanProvider,
-      Stream<Extension<ChangeAttributeFactory>> attrFactories) {
-    ImmutableList<PluginDefinedInfo> result =
-        attrFactories
-            .map(e -> tryCreate(cd, beanProvider, e.getPluginName(), e.get()))
-            .filter(Objects::nonNull)
-            .collect(toImmutableList());
-    return !result.isEmpty() ? result : null;
-  }
-
-  @Nullable
-  private static PluginDefinedInfo tryCreate(
-      ChangeData cd, BeanProvider beanProvider, String plugin, ChangeAttributeFactory attrFactory) {
-    PluginDefinedInfo pdi = null;
-    try {
-      pdi = attrFactory.create(cd, beanProvider, plugin);
-    } catch (RuntimeException ex) {
-      logger.atWarning().atMostEvery(1, MINUTES).withCause(ex).log(
-          "error populating attribute on change %s from plugin %s", cd.getId(), plugin);
-    }
-    if (pdi != null) {
-      pdi.name = plugin;
-    }
-    return pdi;
-  }
-
   public static ImmutableListMultimap<Change.Id, PluginDefinedInfo> createAll(
       Collection<ChangeData> cds,
       BeanProvider beanProvider,
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 231359b..b43996e 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -15,8 +15,11 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
@@ -26,6 +29,8 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.RebaseUtil.Base;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -41,13 +46,27 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.diff.Sequence;
+import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.merge.MergeResult;
+import org.eclipse.jgit.merge.ResolveMerger;
 import org.eclipse.jgit.merge.ThreeWayMerger;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/**
+ * BatchUpdate operation that rebases a change.
+ *
+ * <p>Can only be executed in a {@link com.google.gerrit.server.update.BatchUpdate} set has a {@link
+ * CodeReviewRevWalk} set as {@link RevWalk} (set via {@link
+ * com.google.gerrit.server.update.BatchUpdate#setRepository(org.eclipse.jgit.lib.Repository,
+ * RevWalk, org.eclipse.jgit.lib.ObjectInserter)}).
+ */
 public class RebaseChangeOp implements BatchUpdateOp {
   public interface Factory {
     RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, ObjectId baseCommitId);
@@ -69,12 +88,13 @@
   private boolean validate = true;
   private boolean checkAddPatchSetPermission = true;
   private boolean forceContentMerge;
+  private boolean allowConflicts;
   private boolean detailedCommitMessage;
   private boolean postMessage = true;
   private boolean sendEmail = true;
   private boolean matchAuthorToCommitterDate = false;
 
-  private RevCommit rebasedCommit;
+  private CodeReviewCommit rebasedCommit;
   private PatchSet.Id rebasedPatchSetId;
   private PatchSetInserter patchSetInserter;
   private PatchSet rebasedPatchSet;
@@ -126,6 +146,19 @@
     return this;
   }
 
+  /**
+   * Allows the rebase to succeed if there are conflicts.
+   *
+   * <p>This setting requires that {@link #forceContentMerge} is set {@code true}. If {@link
+   * #forceContentMerge} is {@code false} this setting has no effect.
+   *
+   * @see #setForceContentMerge(boolean)
+   */
+  public RebaseChangeOp setAllowConflicts(boolean allowConflicts) {
+    this.allowConflicts = allowConflicts;
+    return this;
+  }
+
   public RebaseChangeOp setDetailedCommitMessage(boolean detailedCommitMessage) {
     this.detailedCommitMessage = detailedCommitMessage;
     return this;
@@ -187,13 +220,15 @@
             .setCheckAddPatchSetPermission(checkAddPatchSetPermission)
             .setValidate(validate)
             .setSendEmail(sendEmail);
+
+    if (!rebasedCommit.getFilesWithGitConflicts().isEmpty()
+        && !notes.getChange().isWorkInProgress()) {
+      patchSetInserter.setWorkInProgress(true);
+    }
+
     if (postMessage) {
       patchSetInserter.setMessage(
-          "Patch Set "
-              + rebasedPatchSetId.get()
-              + ": Patch Set "
-              + originalPatchSet.id().get()
-              + " was rebased");
+          messageForRebasedChange(rebasedPatchSetId, originalPatchSet.id(), rebasedCommit));
     }
 
     if (base != null && !base.notes().getChange().isMerged()) {
@@ -208,6 +243,24 @@
     patchSetInserter.updateRepo(ctx);
   }
 
+  private static String messageForRebasedChange(
+      PatchSet.Id rebasePatchSetId, PatchSet.Id originalPatchSetId, CodeReviewCommit commit) {
+    StringBuilder stringBuilder =
+        new StringBuilder(
+            String.format(
+                "Patch Set %d: Patch Set %d was rebased",
+                rebasePatchSetId.get(), originalPatchSetId.get()));
+
+    if (!commit.getFilesWithGitConflicts().isEmpty()) {
+      stringBuilder.append("\n\nThe following files contain Git conflicts:\n");
+      commit.getFilesWithGitConflicts().stream()
+          .sorted()
+          .forEach(filePath -> stringBuilder.append("* ").append(filePath).append("\n"));
+    }
+
+    return stringBuilder.toString();
+  }
+
   @Override
   public boolean updateChange(ChangeContext ctx)
       throws ResourceConflictException, IOException, BadRequestException {
@@ -221,7 +274,7 @@
     patchSetInserter.postUpdate(ctx);
   }
 
-  public RevCommit getRebasedCommit() {
+  public CodeReviewCommit getRebasedCommit() {
     checkState(rebasedCommit != null, "getRebasedCommit() only valid after updateRepo");
     return rebasedCommit;
   }
@@ -254,7 +307,7 @@
    * @throws MergeConflictException the rebase failed due to a merge conflict.
    * @throws IOException the merge failed for another reason.
    */
-  private RevCommit rebaseCommit(
+  private CodeReviewCommit rebaseCommit(
       RepoContext ctx, RevCommit original, ObjectId base, String commitMessage)
       throws ResourceConflictException, IOException {
     RevCommit parentCommit = original.getParent(0);
@@ -266,15 +319,56 @@
     ThreeWayMerger merger =
         newMergeUtil().newThreeWayMerger(ctx.getInserter(), ctx.getRepoView().getConfig());
     merger.setBase(parentCommit);
+
+    DirCache dc = DirCache.newInCore();
+    if (allowConflicts && merger instanceof ResolveMerger) {
+      // The DirCache must be set on ResolveMerger before calling
+      // ResolveMerger#merge(AnyObjectId...) otherwise the entries in DirCache don't get populated.
+      ((ResolveMerger) merger).setDirCache(dc);
+    }
+
     boolean success = merger.merge(original, base);
 
-    if (!success || merger.getResultTreeId() == null) {
-      throw new MergeConflictException(
-          "The change could not be rebased due to a conflict during merge.");
+    ObjectId tree;
+    ImmutableSet<String> filesWithGitConflicts;
+    if (success) {
+      filesWithGitConflicts = null;
+      tree = merger.getResultTreeId();
+    } else {
+      List<String> conflicts = ImmutableList.of();
+      if (merger instanceof ResolveMerger) {
+        conflicts = ((ResolveMerger) merger).getUnmergedPaths();
+      }
+
+      if (!allowConflicts || !(merger instanceof ResolveMerger)) {
+        throw new MergeConflictException(
+            "The change could not be rebased due to a conflict during merge.\n\n"
+                + MergeUtil.createConflictMessage(conflicts));
+      }
+
+      Map<String, MergeResult<? extends Sequence>> mergeResults =
+          ((ResolveMerger) merger).getMergeResults();
+
+      filesWithGitConflicts =
+          mergeResults.entrySet().stream()
+              .filter(e -> e.getValue().containsConflicts())
+              .map(Map.Entry::getKey)
+              .collect(toImmutableSet());
+
+      tree =
+          MergeUtil.mergeWithConflicts(
+              ctx.getRevWalk(),
+              ctx.getInserter(),
+              dc,
+              "PATCH SET",
+              original,
+              "BASE",
+              ctx.getRevWalk().parseCommit(base),
+              mergeResults);
     }
 
     CommitBuilder cb = new CommitBuilder();
-    cb.setTreeId(merger.getResultTreeId());
+    cb.setTreeId(tree);
     cb.setParentId(base);
     cb.setAuthor(original.getAuthorIdent());
     cb.setMessage(commitMessage);
@@ -290,6 +384,8 @@
     }
     ObjectId objectId = ctx.getInserter().insert(cb);
     ctx.getInserter().flush();
-    return ctx.getRevWalk().parseCommit(objectId);
+    CodeReviewCommit commit = ((CodeReviewRevWalk) ctx.getRevWalk()).parseCommit(objectId);
+    commit.setFilesWithGitConflicts(filesWithGitConflicts);
+    return commit;
   }
 }
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerAdder.java
index 3d986d2..5d55b4d 100644
--- a/java/com/google/gerrit/server/change/ReviewerAdder.java
+++ b/java/com/google/gerrit/server/change/ReviewerAdder.java
@@ -19,7 +19,6 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
-import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
@@ -140,12 +139,12 @@
     }
 
     logger.atFine().log(
-        "Adding account %d from author/committer identity of commit %s as reviewer to change %d",
+        "Adding account %d from author/committer identity of commit %s as cc to change %d",
         accountId.get(), commitId.name(), change.getChangeId());
 
     InternalAddReviewerInput in = new InternalAddReviewerInput();
     in.reviewer = accountId.toString();
-    in.state = REVIEWER;
+    in.state = CC;
     in.notify = notify;
     in.otherFailureBehavior = FailureBehavior.IGNORE;
     return Optional.of(in);
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index a3136d4a..761b57d 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -32,7 +32,6 @@
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
@@ -47,20 +46,17 @@
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
   private final AccountLoader.Factory accountLoaderFactory;
-  private final SubmitRuleEvaluator submitRuleEvaluator;
 
   @Inject
   ReviewerJson(
       PermissionBackend permissionBackend,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
-      AccountLoader.Factory accountLoaderFactory,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
+      AccountLoader.Factory accountLoaderFactory) {
     this.permissionBackend = permissionBackend;
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
     this.accountLoaderFactory = accountLoaderFactory;
-    submitRuleEvaluator = submitRuleEvaluatorFactory.create(SubmitRuleOptions.defaults());
   }
 
   public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs)
@@ -123,7 +119,7 @@
     if (ps != null) {
       PermissionBackend.ForChange perm = permissionBackend.absentUser(reviewerAccountId).change(cd);
 
-      for (SubmitRecord rec : submitRuleEvaluator.evaluate(cd)) {
+      for (SubmitRecord rec : cd.submitRecords(SubmitRuleOptions.defaults())) {
         if (rec.labels == null) {
           continue;
         }
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index 414107f..b702440 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -164,7 +164,12 @@
    * RevWalk and assumes it is backed by an open repository.
    */
   public CommitInfo getCommitInfo(
-      Project.NameKey project, RevWalk rw, RevCommit commit, boolean addLinks, boolean fillCommit)
+      Project.NameKey project,
+      RevWalk rw,
+      RevCommit commit,
+      boolean addLinks,
+      boolean fillCommit,
+      String branchName)
       throws IOException {
     CommitInfo info = new CommitInfo();
     if (fillCommit) {
@@ -177,7 +182,8 @@
     info.message = commit.getFullMessage();
 
     if (addLinks) {
-      ImmutableList<WebLinkInfo> links = webLinks.getPatchSetLinks(project, commit.name());
+      ImmutableList<WebLinkInfo> links =
+          webLinks.getPatchSetLinks(project, commit.name(), commit.getFullMessage(), branchName);
       info.webLinks = links.isEmpty() ? null : links;
     }
 
@@ -187,7 +193,8 @@
       i.commit = parent.name();
       i.subject = parent.getShortMessage();
       if (addLinks) {
-        ImmutableList<WebLinkInfo> parentLinks = webLinks.getParentLinks(project, parent.name());
+        ImmutableList<WebLinkInfo> parentLinks =
+            webLinks.getParentLinks(project, parent.name(), parent.getFullMessage(), branchName);
         i.webLinks = parentLinks.isEmpty() ? null : parentLinks;
       }
       info.parents.add(i);
@@ -288,11 +295,12 @@
       String rev = in.commitId().name();
       RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
       rw.parseBody(commit);
+      String branchName = cd.change().getDest().branch();
       if (setCommit) {
-        out.commit = getCommitInfo(project, rw, commit, has(WEB_LINKS), fillCommit);
+        out.commit = getCommitInfo(project, rw, commit, has(WEB_LINKS), fillCommit, branchName);
       }
       if (addFooters) {
-        Ref ref = repo.exactRef(cd.change().getDest().branch());
+        Ref ref = repo.exactRef(branchName);
         RevCommit mergeTip = null;
         if (ref != null) {
           mergeTip = rw.parseCommit(ref.getObjectId());
@@ -307,7 +315,7 @@
 
     if (has(ALL_FILES) || (out.isCurrent && has(CURRENT_FILES))) {
       try {
-        out.files = fileInfoJson.toFileInfoMap(c, in);
+        out.files = fileInfoJson.getFileInfoMap(c, in);
         out.files.remove(Patch.COMMIT_MSG);
         out.files.remove(Patch.MERGE_LIST);
       } catch (ResourceConflictException e) {
@@ -319,7 +327,7 @@
       actionJson.addRevisionActions(
           changeInfo,
           out,
-          new RevisionResource(changeResourceFactory.create(cd.notes(), userProvider.get()), in));
+          new RevisionResource(changeResourceFactory.create(cd, userProvider.get()), in));
     }
 
     if (gpgApi.isEnabled() && has(PUSH_CERTIFICATES)) {
diff --git a/java/com/google/gerrit/server/restapi/change/SetTopicOp.java b/java/com/google/gerrit/server/change/SetTopicOp.java
similarity index 88%
rename from java/com/google/gerrit/server/restapi/change/SetTopicOp.java
rename to java/com/google/gerrit/server/change/SetTopicOp.java
index 9eff5c1..c4a49b0 100644
--- a/java/com/google/gerrit/server/restapi/change/SetTopicOp.java
+++ b/java/com/google/gerrit/server/change/SetTopicOp.java
@@ -12,12 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.restapi.change;
+package com.google.gerrit.server.change;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.extensions.api.changes.TopicInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.extensions.events.TopicEdited;
@@ -31,10 +31,10 @@
 
 public class SetTopicOp implements BatchUpdateOp {
   public interface Factory {
-    SetTopicOp create(TopicInput input);
+    SetTopicOp create(@Nullable String topic);
   }
 
-  private final TopicInput input;
+  private final String topic;
   private final TopicEdited topicEdited;
   private final ChangeMessagesUtil cmUtil;
 
@@ -44,8 +44,8 @@
 
   @Inject
   public SetTopicOp(
-      TopicEdited topicEdited, ChangeMessagesUtil cmUtil, @Assisted TopicInput input) {
-    this.input = input;
+      TopicEdited topicEdited, ChangeMessagesUtil cmUtil, @Nullable @Assisted String topic) {
+    this.topic = topic;
     this.topicEdited = topicEdited;
     this.cmUtil = cmUtil;
   }
@@ -54,7 +54,7 @@
   public boolean updateChange(ChangeContext ctx) throws BadRequestException {
     change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-    newTopicName = Strings.nullToEmpty(input.topic);
+    newTopicName = Strings.nullToEmpty(topic);
     oldTopicName = Strings.nullToEmpty(change.getTopic());
     if (oldTopicName.equals(newTopicName)) {
       return false;
diff --git a/java/com/google/gerrit/server/comment/CommentContextCache.java b/java/com/google/gerrit/server/comment/CommentContextCache.java
new file mode 100644
index 0000000..c954a38
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextCache.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License
+
+package com.google.gerrit.server.comment;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.CommentContext;
+
+/**
+ * Caches the context lines of comments (source file content surrounding and including the lines
+ * where the comment was written)
+ */
+public interface CommentContextCache {
+
+  /**
+   * Returns the context lines for a single comment. Works for published and draft comments.
+   *
+   * @param key a key representing a subset of fields for a comment that serves as an identifier.
+   * @return a {@link CommentContext} object containing all line numbers and text of the context.
+   */
+  CommentContext get(CommentContextKey key);
+
+  /**
+   * Returns the context lines for multiple comments - identified by their {@code keys}. Works for
+   * published and draft comments.
+   *
+   * @param keys list of keys, where each key represents a single comment through its project,
+   *     change ID, patchset, path and ID. The keys can belong to different projects and changes.
+   * @return {@code Map} of {@code CommentContext} containing the context for all comments.
+   */
+  ImmutableMap<CommentContextKey, CommentContext> getAll(Iterable<CommentContextKey> keys);
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
new file mode 100644
index 0000000..e12b538
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
@@ -0,0 +1,313 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License
+
+package com.google.gerrit.server.comment;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto;
+import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto.CommentContextProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.comment.CommentContextLoader.ContextInput;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/** Implementation of {@link CommentContextCache}. */
+public class CommentContextCacheImpl implements CommentContextCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String CACHE_NAME = "comment_context";
+
+  /**
+   * Comment context is expected to contain just few lines of code to be displayed beside the
+   * comment. Setting an upper bound of 100 for padding.
+   */
+  @VisibleForTesting public static final int MAX_CONTEXT_PADDING = 50;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        persist(CACHE_NAME, CommentContextKey.class, CommentContext.class)
+            .version(5)
+            .diskLimit(1 << 30) // limit the total cache size to 1 GB
+            .maximumWeight(1 << 23) // Limit the size of the in-memory cache to 8 MB
+            .weigher(CommentContextWeigher.class)
+            .keySerializer(CommentContextKey.Serializer.INSTANCE)
+            .valueSerializer(CommentContextSerializer.INSTANCE)
+            .loader(Loader.class);
+
+        bind(CommentContextCache.class).to(CommentContextCacheImpl.class);
+      }
+    };
+  }
+
+  private final LoadingCache<CommentContextKey, CommentContext> contextCache;
+
+  @Inject
+  CommentContextCacheImpl(
+      @Named(CACHE_NAME) LoadingCache<CommentContextKey, CommentContext> contextCache) {
+    this.contextCache = contextCache;
+  }
+
+  @Override
+  public CommentContext get(CommentContextKey comment) {
+    return getAll(ImmutableList.of(comment)).get(comment);
+  }
+
+  @Override
+  public ImmutableMap<CommentContextKey, CommentContext> getAll(
+      Iterable<CommentContextKey> inputKeys) {
+    ImmutableMap.Builder<CommentContextKey, CommentContext> result = ImmutableMap.builder();
+
+    List<CommentContextKey> adjustedKeys =
+        Streams.stream(inputKeys)
+            .map(CommentContextCacheImpl::adjustMaxContextPadding)
+            .collect(ImmutableList.toImmutableList());
+
+    // Convert the input keys to the same keys but with their file paths hashed
+    Map<CommentContextKey, CommentContextKey> keysToCacheKeys =
+        adjustedKeys.stream()
+            .collect(
+                Collectors.toMap(
+                    Function.identity(),
+                    k -> k.toBuilder().path(Loader.hashPath(k.path())).build()));
+
+    try {
+      ImmutableMap<CommentContextKey, CommentContext> allContext =
+          contextCache.getAll(keysToCacheKeys.values());
+
+      for (CommentContextKey inputKey : inputKeys) {
+        CommentContextKey cacheKey = keysToCacheKeys.get(adjustMaxContextPadding(inputKey));
+        result.put(inputKey, allContext.get(cacheKey));
+      }
+      return result.build();
+    } catch (ExecutionException e) {
+      throw new StorageException("Failed to retrieve comments' context", e);
+    }
+  }
+
+  private static CommentContextKey adjustMaxContextPadding(CommentContextKey key) {
+    if (key.contextPadding() < 0) {
+      logger.atWarning().log(
+          "Cannot set context padding to a negative number %d. Adjusting the number to 0",
+          key.contextPadding());
+      return key.toBuilder().contextPadding(0).build();
+    }
+    if (key.contextPadding() > MAX_CONTEXT_PADDING) {
+      logger.atWarning().log(
+          "Number of requested context lines is %d and exceeding the configured maximum of %d."
+              + " Adjusting the number to the maximum.",
+          key.contextPadding(), MAX_CONTEXT_PADDING);
+      return key.toBuilder().contextPadding(MAX_CONTEXT_PADDING).build();
+    }
+    return key;
+  }
+
+  public enum CommentContextSerializer implements CacheSerializer<CommentContext> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(CommentContext commentContext) {
+      AllCommentContextProto.Builder allBuilder = AllCommentContextProto.newBuilder();
+      allBuilder.setContentType(commentContext.contentType());
+
+      commentContext
+          .lines()
+          .entrySet()
+          .forEach(
+              c ->
+                  allBuilder.addContext(
+                      CommentContextProto.newBuilder()
+                          .setLineNumber(c.getKey())
+                          .setContextLine(c.getValue())));
+      return Protos.toByteArray(allBuilder.build());
+    }
+
+    @Override
+    public CommentContext deserialize(byte[] in) {
+      ImmutableMap.Builder<Integer, String> contextLinesMap = ImmutableMap.builder();
+      AllCommentContextProto proto = Protos.parseUnchecked(AllCommentContextProto.parser(), in);
+      proto.getContextList().stream()
+          .forEach(c -> contextLinesMap.put(c.getLineNumber(), c.getContextLine()));
+      return CommentContext.create(contextLinesMap.build(), proto.getContentType());
+    }
+  }
+
+  static class Loader extends CacheLoader<CommentContextKey, CommentContext> {
+    private final ChangeNotes.Factory notesFactory;
+    private final CommentsUtil commentsUtil;
+    private final CommentContextLoader.Factory factory;
+
+    @Inject
+    Loader(
+        CommentsUtil commentsUtil,
+        ChangeNotes.Factory notesFactory,
+        CommentContextLoader.Factory factory) {
+      this.commentsUtil = commentsUtil;
+      this.notesFactory = notesFactory;
+      this.factory = factory;
+    }
+
+    /**
+     * Load the comment context of a single comment identified by its key.
+     *
+     * @param key a {@link CommentContextKey} identifying a comment.
+     * @return the comment context associated with the comment.
+     * @throws IOException an error happened while parsing the commit or loading the file where the
+     *     comment is written.
+     */
+    @Override
+    public CommentContext load(CommentContextKey key) throws IOException {
+      return loadAll(ImmutableList.of(key)).get(key);
+    }
+
+    /**
+     * Load the comment context of different comments identified by their keys.
+     *
+     * @param keys list of {@link CommentContextKey} identifying some comments.
+     * @return a map of the input keys to their corresponding comment context.
+     * @throws IOException an error happened while parsing the commits or loading the files where
+     *     the comments are written.
+     */
+    @Override
+    public Map<CommentContextKey, CommentContext> loadAll(
+        Iterable<? extends CommentContextKey> keys) throws IOException {
+      ImmutableMap.Builder<CommentContextKey, CommentContext> result =
+          ImmutableMap.builderWithExpectedSize(Iterables.size(keys));
+
+      Map<Project.NameKey, Map<Change.Id, List<CommentContextKey>>> groupedKeys =
+          Streams.stream(keys)
+              .distinct()
+              .map(k -> (CommentContextKey) k)
+              .collect(
+                  Collectors.groupingBy(
+                      CommentContextKey::project,
+                      Collectors.groupingBy(CommentContextKey::changeId)));
+
+      for (Map.Entry<Project.NameKey, Map<Change.Id, List<CommentContextKey>>> perProject :
+          groupedKeys.entrySet()) {
+        Map<Change.Id, List<CommentContextKey>> keysPerProject = perProject.getValue();
+
+        for (Map.Entry<Change.Id, List<CommentContextKey>> perChange : keysPerProject.entrySet()) {
+          Map<CommentContextKey, CommentContext> context =
+              loadForSameChange(perChange.getValue(), perProject.getKey(), perChange.getKey());
+          result.putAll(context);
+        }
+      }
+      return result.build();
+    }
+
+    /**
+     * Load the comment context for comments (published and drafts) of the same project and change
+     * ID.
+     *
+     * @param keys a list of keys corresponding to some comments
+     * @param project a gerrit project/repository
+     * @param changeId an identifier for a change
+     * @return a map of the input keys to their corresponding {@link CommentContext}
+     */
+    private Map<CommentContextKey, CommentContext> loadForSameChange(
+        List<CommentContextKey> keys, Project.NameKey project, Change.Id changeId)
+        throws IOException {
+      ChangeNotes notes = notesFactory.createChecked(project, changeId);
+      List<HumanComment> humanComments = commentsUtil.publishedHumanCommentsByChange(notes);
+      List<HumanComment> drafts = commentsUtil.draftByChange(notes);
+      List<HumanComment> allComments =
+          Streams.concat(humanComments.stream(), drafts.stream()).collect(Collectors.toList());
+      CommentContextLoader loader = factory.create(project);
+      Map<ContextInput, CommentContextKey> commentsToKeys = new HashMap<>();
+      for (CommentContextKey key : keys) {
+        Comment comment = getCommentForKey(allComments, key);
+        commentsToKeys.put(ContextInput.fromComment(comment, key.contextPadding()), key);
+      }
+      Map<ContextInput, CommentContext> allContext = loader.getContext(commentsToKeys.keySet());
+      return allContext.entrySet().stream()
+          .collect(Collectors.toMap(e -> commentsToKeys.get(e.getKey()), Map.Entry::getValue));
+    }
+
+    /**
+     * Return the single comment from the {@code allComments} input list corresponding to the key
+     * parameter.
+     *
+     * @param allComments a list of comments.
+     * @param key a key representing a single comment.
+     * @return the single comment corresponding to the key parameter.
+     */
+    private Comment getCommentForKey(List<HumanComment> allComments, CommentContextKey key) {
+      return allComments.stream()
+          .filter(
+              c ->
+                  key.id().equals(c.key.uuid)
+                      && key.patchset() == c.key.patchSetId
+                      && key.path().equals(hashPath(c.key.filename)))
+          .findFirst()
+          .orElseThrow(() -> new IllegalArgumentException("Unable to find comment for key " + key));
+    }
+
+    /**
+     * Hash an input String using the general {@link Hashing#murmur3_128()} hash.
+     *
+     * @param input the input String
+     * @return a hashed representation of the input String
+     */
+    static String hashPath(String input) {
+      return Hashing.murmur3_128().hashString(input, UTF_8).toString();
+    }
+  }
+
+  private static class CommentContextWeigher implements Weigher<CommentContextKey, CommentContext> {
+    @Override
+    public int weigh(CommentContextKey key, CommentContext commentContext) {
+      int size = 0;
+      size += key.id().length();
+      size += key.path().length();
+      size += key.project().get().length();
+      size += 4;
+      for (String line : commentContext.lines().values()) {
+        size += 4; // line number
+        size += line.length(); // number of characters in the context line
+      }
+      return size;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextKey.java b/java/com/google/gerrit/server/comment/CommentContextKey.java
new file mode 100644
index 0000000..af2ae92
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextKey.java
@@ -0,0 +1,88 @@
+package com.google.gerrit.server.comment;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+
+/**
+ * An identifier of a comment that should be used to load the comment context using {@link
+ * CommentContextCache#get(CommentContextKey)}, or {@link CommentContextCache#getAll(Iterable)}.
+ *
+ * <p>The {@link CommentContextCacheImpl} implementation uses this class as the cache key, while
+ * replacing the {@link #path()} field with the hashed path.
+ */
+@AutoValue
+public abstract class CommentContextKey {
+  abstract Project.NameKey project();
+
+  abstract Change.Id changeId();
+
+  /** The unique comment ID. */
+  abstract String id();
+
+  /** File path at which the comment was written. */
+  abstract String path();
+
+  abstract Integer patchset();
+
+  /** Number of extra lines of context that should be added before and after the comment range. */
+  abstract int contextPadding();
+
+  abstract Builder toBuilder();
+
+  public static Builder builder() {
+    return new AutoValue_CommentContextKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder project(Project.NameKey nameKey);
+
+    public abstract Builder changeId(Change.Id changeId);
+
+    public abstract Builder id(String id);
+
+    public abstract Builder path(String path);
+
+    public abstract Builder patchset(Integer patchset);
+
+    public abstract Builder contextPadding(Integer numLines);
+
+    public abstract CommentContextKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<CommentContextKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(CommentContextKey key) {
+      return Protos.toByteArray(
+          Cache.CommentContextKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setChangeId(key.changeId().toString())
+              .setPatchset(key.patchset())
+              .setPathHash(key.path())
+              .setCommentId(key.id())
+              .setContextPadding(key.contextPadding())
+              .build());
+    }
+
+    @Override
+    public CommentContextKey deserialize(byte[] in) {
+      Cache.CommentContextKeyProto proto =
+          Protos.parseUnchecked(Cache.CommentContextKeyProto.parser(), in);
+      return CommentContextKey.builder()
+          .project(Project.NameKey.parse(proto.getProject()))
+          .changeId(Change.Id.tryParse(proto.getChangeId()).get())
+          .patchset(proto.getPatchset())
+          .id(proto.getCommentId())
+          .path(proto.getPathHash())
+          .contextPadding(proto.getContextPadding())
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextLoader.java b/java/com/google/gerrit/server/comment/CommentContextLoader.java
new file mode 100644
index 0000000..a5aca48
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextLoader.java
@@ -0,0 +1,293 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.comment;
+
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
+import static com.google.gerrit.entities.Patch.MERGE_LIST;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.PatchScript;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.ContextLineInfo;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mime.FileTypeRegistry;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.SrcContentResolver;
+import com.google.gerrit.server.patch.Text;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import eu.medsea.mimeutil.MimeType;
+import eu.medsea.mimeutil.MimeUtil2;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * Computes the list of {@link ContextLineInfo} for a given comment, that is, the lines of the
+ * source file surrounding and including the area where the comment was written.
+ */
+public class CommentContextLoader {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final FileTypeRegistry registry;
+  private final GitRepositoryManager repoManager;
+  private final Project.NameKey project;
+  private final ProjectState projectState;
+
+  public interface Factory {
+    CommentContextLoader create(Project.NameKey project);
+  }
+
+  @Inject
+  CommentContextLoader(
+      FileTypeRegistry registry,
+      GitRepositoryManager repoManager,
+      ProjectCache projectCache,
+      @Assisted Project.NameKey project) {
+    this.registry = registry;
+    this.repoManager = repoManager;
+    this.project = project;
+    projectState = projectCache.get(project).orElseThrow(illegalState(project));
+  }
+
+  /**
+   * Load the comment context for multiple contextInputs at once. This method will open the
+   * repository and read the source files for all necessary contextInputs' file paths.
+   *
+   * @param contextInputs a list of contextInputs.
+   * @return a Map where all entries consist of the input contextInputs and the values are their
+   *     corresponding {@link CommentContext}.
+   */
+  public Map<ContextInput, CommentContext> getContext(Collection<ContextInput> contextInputs)
+      throws IOException {
+    ImmutableMap.Builder<ContextInput, CommentContext> result =
+        ImmutableMap.builderWithExpectedSize(Iterables.size(contextInputs));
+
+    // Group contextInputs by commit ID so that each commit is parsed only once
+    Map<ObjectId, List<ContextInput>> commentsByCommitId =
+        contextInputs.stream().collect(groupingBy(ContextInput::commitId));
+
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      for (ObjectId commitId : commentsByCommitId.keySet()) {
+        RevCommit commit = rw.parseCommit(commitId);
+        for (ContextInput contextInput : commentsByCommitId.get(commitId)) {
+          Optional<Range> range = getStartAndEndLines(contextInput);
+          if (!range.isPresent()) {
+            result.put(contextInput, CommentContext.empty());
+            continue;
+          }
+          String filePath = contextInput.filePath();
+          switch (filePath) {
+            case COMMIT_MSG:
+              result.put(
+                  contextInput,
+                  getContextForCommitMessage(
+                      rw.getObjectReader(), commit, range.get(), contextInput.contextPadding()));
+              break;
+            case MERGE_LIST:
+              result.put(
+                  contextInput,
+                  getContextForMergeList(
+                      rw.getObjectReader(), commit, range.get(), contextInput.contextPadding()));
+              break;
+            default:
+              result.put(
+                  contextInput,
+                  getContextForFilePath(
+                      repo, rw, commit, filePath, range.get(), contextInput.contextPadding()));
+          }
+        }
+      }
+      return result.build();
+    }
+  }
+
+  private CommentContext getContextForCommitMessage(
+      ObjectReader reader, RevCommit commit, Range commentRange, int contextPadding)
+      throws IOException {
+    Text text = Text.forCommit(reader, commit);
+    return createContext(
+        text, commentRange, contextPadding, FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE);
+  }
+
+  private CommentContext getContextForMergeList(
+      ObjectReader reader, RevCommit commit, Range commentRange, int contextPadding)
+      throws IOException {
+    ComparisonType cmp = ComparisonType.againstParent(1);
+    Text text = Text.forMergeList(cmp, reader, commit);
+    return createContext(
+        text, commentRange, contextPadding, FileContentUtil.TEXT_X_GERRIT_MERGE_LIST);
+  }
+
+  private CommentContext getContextForFilePath(
+      Repository repo,
+      RevWalk rw,
+      RevCommit commit,
+      String filePath,
+      Range commentRange,
+      int contextPadding)
+      throws IOException {
+    // TODO(ghareeb): We can further group the comments by file paths to avoid opening
+    // the same file multiple times.
+    try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), filePath, commit.getTree())) {
+      if (tw == null) {
+        logger.atWarning().log(
+            "Could not find path %s in the git tree of ID %s.", filePath, commit.getTree().getId());
+        return CommentContext.empty();
+      }
+      ObjectId id = tw.getObjectId(0);
+      byte[] sourceContent = SrcContentResolver.getSourceContent(repo, id, tw.getFileMode(0));
+      Text textSrc = new Text(sourceContent);
+      String contentType = getContentType(tw, filePath, textSrc);
+      return createContext(textSrc, commentRange, contextPadding, contentType);
+    }
+  }
+
+  private String getContentType(TreeWalk tw, String filePath, Text src) {
+    PatchScript.FileMode fileMode = PatchScript.FileMode.fromJgitFileMode(tw.getFileMode(0));
+    String mimeType = MimeUtil2.UNKNOWN_MIME_TYPE.toString();
+    if (src.size() > 0 && PatchScript.FileMode.SYMLINK != fileMode) {
+      MimeType registryMimeType = registry.getMimeType(filePath, src.getContent());
+      mimeType = registryMimeType.toString();
+    }
+    return FileContentUtil.resolveContentType(projectState, filePath, fileMode, mimeType);
+  }
+
+  private static CommentContext createContext(
+      Text src, Range commentRange, int contextPadding, String contentType) {
+    if (commentRange.start() < 1 || commentRange.end() - 1 > src.size()) {
+      // TODO(ghareeb): We should throw an exception in this case. See
+      // https://bugs.chromium.org/p/gerrit/issues/detail?id=14102 which is an example where the
+      // diff contains an extra line not in the original file.
+      return CommentContext.empty();
+    }
+    commentRange = adjustRange(commentRange, contextPadding, src.size());
+    ImmutableMap.Builder<Integer, String> context =
+        ImmutableMap.builderWithExpectedSize(commentRange.end() - commentRange.start());
+    for (int i = commentRange.start(); i < commentRange.end(); i++) {
+      context.put(i, src.getString(i - 1));
+    }
+    return CommentContext.create(context.build(), contentType);
+  }
+
+  /**
+   * Adjust the {@code commentRange} parameter by adding {@code contextPadding} lines before and
+   * after the comment range.
+   */
+  private static Range adjustRange(Range commentRange, int contextPadding, int fileLines) {
+    int newStartLine = commentRange.start() - contextPadding;
+    int newEndLine = commentRange.end() + contextPadding;
+    return Range.create(Math.max(1, newStartLine), Math.min(fileLines + 1, newEndLine));
+  }
+
+  private static Optional<Range> getStartAndEndLines(ContextInput comment) {
+    if (comment.range() != null) {
+      return Optional.of(Range.create(comment.range().startLine, comment.range().endLine + 1));
+    } else if (comment.lineNumber() > 0) {
+      return Optional.of(Range.create(comment.lineNumber(), comment.lineNumber() + 1));
+    }
+    return Optional.empty();
+  }
+
+  @AutoValue
+  abstract static class Range {
+    static Range create(int start, int end) {
+      return new AutoValue_CommentContextLoader_Range(start, end);
+    }
+
+    /** Start line of the comment (inclusive). */
+    abstract int start();
+
+    /** End line of the comment (exclusive). */
+    abstract int end();
+
+    /** Number of lines covered by this range. */
+    int size() {
+      return end() - start();
+    }
+  }
+
+  /** This entity only contains comment fields needed to load the comment context. */
+  @AutoValue
+  abstract static class ContextInput {
+    static ContextInput fromComment(Comment comment, int contextPadding) {
+      return new AutoValue_CommentContextLoader_ContextInput.Builder()
+          .commitId(comment.getCommitId())
+          .filePath(comment.key.filename)
+          .range(comment.range)
+          .lineNumber(comment.lineNbr)
+          .contextPadding(contextPadding)
+          .build();
+    }
+
+    /** 20 bytes SHA-1 of the patchset commit containing the file where the comment is written. */
+    abstract ObjectId commitId();
+
+    /** File path where the comment is written. */
+    abstract String filePath();
+
+    /**
+     * Position of the comment in the file (start line, start char, end line, end char). This field
+     * can be null if the range is not available for this comment.
+     */
+    @Nullable
+    abstract Comment.Range range();
+
+    /**
+     * The 1-based line number where the comment is written. A value 0 means that the line number is
+     * not available for this comment.
+     */
+    abstract Integer lineNumber();
+
+    /** Number of extra lines of context that should be added before and after the comment range. */
+    abstract Integer contextPadding();
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+
+      public abstract Builder commitId(ObjectId commitId);
+
+      public abstract Builder filePath(String filePath);
+
+      public abstract Builder range(@Nullable Comment.Range range);
+
+      public abstract Builder lineNumber(Integer lineNumber);
+
+      public abstract Builder contextPadding(Integer contextPadding);
+
+      public abstract ContextInput build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/config/ConfigUtil.java b/java/com/google/gerrit/server/config/ConfigUtil.java
index 43c05e0..27ded63 100644
--- a/java/com/google/gerrit/server/config/ConfigUtil.java
+++ b/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -16,6 +16,7 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.flogger.FluentLogger;
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Modifier;
@@ -30,6 +31,8 @@
 import org.eclipse.jgit.lib.Config;
 
 public class ConfigUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   @SuppressWarnings("unchecked")
   private static <T> T[] allValuesOf(T defaultValue) {
     try {
@@ -138,7 +141,12 @@
     } else {
       for (String string : values) {
         if (string != null) {
-          list.add(getEnum(section, subsection, setting, string, all));
+          try {
+            list.add(getEnum(section, subsection, setting, string, all));
+          } catch (IllegalArgumentException ex) {
+            // It's better to ignore a wrongly configured enum, rather than fail to load Gerrit.
+            logger.atWarning().log(ex.getMessage());
+          }
         }
       }
     }
diff --git a/java/com/google/gerrit/server/config/EnableReverseDnsLookup.java b/java/com/google/gerrit/server/config/EnablePeerIPInReflogRecord.java
similarity index 88%
rename from java/com/google/gerrit/server/config/EnableReverseDnsLookup.java
rename to java/com/google/gerrit/server/config/EnablePeerIPInReflogRecord.java
index ec57338..708bb15 100644
--- a/java/com/google/gerrit/server/config/EnableReverseDnsLookup.java
+++ b/java/com/google/gerrit/server/config/EnablePeerIPInReflogRecord.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.
@@ -21,4 +21,4 @@
 
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface EnableReverseDnsLookup {}
+public @interface EnablePeerIPInReflogRecord {}
diff --git a/java/com/google/gerrit/server/config/EnableReverseDnsLookupProvider.java b/java/com/google/gerrit/server/config/EnablePeerIPInReflogRecordProvider.java
similarity index 64%
rename from java/com/google/gerrit/server/config/EnableReverseDnsLookupProvider.java
rename to java/com/google/gerrit/server/config/EnablePeerIPInReflogRecordProvider.java
index 71086a9..c274b84 100644
--- a/java/com/google/gerrit/server/config/EnableReverseDnsLookupProvider.java
+++ b/java/com/google/gerrit/server/config/EnablePeerIPInReflogRecordProvider.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.
@@ -18,16 +18,17 @@
 import com.google.inject.Provider;
 import org.eclipse.jgit.lib.Config;
 
-public class EnableReverseDnsLookupProvider implements Provider<Boolean> {
-  private final Boolean enableReverseDnsLookup;
+public class EnablePeerIPInReflogRecordProvider implements Provider<Boolean> {
+  private final Boolean enablePeerIPInReflogRecord;
 
   @Inject
-  EnableReverseDnsLookupProvider(@GerritServerConfig Config config) {
-    enableReverseDnsLookup = config.getBoolean("gerrit", null, "enableReverseDnsLookup", false);
+  EnablePeerIPInReflogRecordProvider(@GerritServerConfig Config config) {
+    enablePeerIPInReflogRecord =
+        config.getBoolean("gerrit", null, "enablePeerIPInReflogRecord", false);
   }
 
   @Override
   public Boolean get() {
-    return enableReverseDnsLookup;
+    return enablePeerIPInReflogRecord;
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 78dd38c..bb851e2 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -81,6 +81,7 @@
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.ExceptionHookImpl;
+import com.google.gerrit.server.ExternalUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.TraceRequestListener;
@@ -100,21 +101,20 @@
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
-import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.server.avatar.AvatarProvider;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.change.AbandonOp;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeETagComputation;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
-import com.google.gerrit.server.change.LabelsJson;
+import com.google.gerrit.server.change.FileInfoJsonModule;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.change.RevisionJson;
+import com.google.gerrit.server.comment.CommentContextCacheImpl;
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.events.EventListener;
 import com.google.gerrit.server.events.EventsMetrics;
@@ -130,6 +130,7 @@
 import com.google.gerrit.server.git.ReceivePackInitializer;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.receive.PluginPushOption;
 import com.google.gerrit.server.git.receive.ReceiveCommitsModule;
 import com.google.gerrit.server.git.validators.CommentCountValidator;
 import com.google.gerrit.server.git.validators.CommentCumulativeSizeValidator;
@@ -162,6 +163,7 @@
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
 import com.google.gerrit.server.notedb.NoteDbModule;
+import com.google.gerrit.server.patch.DiffOperationsImpl;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
 import com.google.gerrit.server.patch.PatchScriptFactory;
 import com.google.gerrit.server.patch.PatchScriptFactoryForAutoFix;
@@ -180,6 +182,7 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
 import com.google.gerrit.server.quota.QuotaEnforcer;
+import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
@@ -218,12 +221,10 @@
 /** Starts global state with standard dependencies. */
 public class GerritGlobalModule extends FactoryModule {
   private final Config cfg;
-  private final AuthModule authModule;
 
   @Inject
-  GerritGlobalModule(@GerritServerConfig Config cfg, AuthModule authModule) {
+  GerritGlobalModule(@GerritServerConfig Config cfg) {
     this.cfg = cfg;
-    this.authModule = authModule;
   }
 
   @Override
@@ -233,7 +234,6 @@
     bind(IdGenerator.class);
     bind(RulesCache.class);
     bind(BlameCache.class).to(BlameCacheImpl.class);
-    install(authModule);
     install(AccountCacheImpl.module());
     install(BatchUpdate.module());
     install(ChangeKindCacheImpl.module());
@@ -246,11 +246,12 @@
     install(ServiceUserClassifierImpl.module());
     install(PatchListCacheImpl.module());
     install(ProjectCacheImpl.module());
+    install(DiffOperationsImpl.module());
     install(SectionSortCache.module());
     install(SubmitStrategy.module());
     install(TagCache.module());
-    install(OAuthTokenCache.module());
     install(PureRevertCache.module());
+    install(CommentContextCacheImpl.module());
 
     install(new AccessControlModule());
     install(new CmdLineParserModule());
@@ -265,19 +266,20 @@
     install(new IgnoreSelfApprovalRule.Module());
     install(new ReceiveCommitsModule());
     install(new SshAddressesModule());
+    install(new FileInfoJsonModule(cfg));
     install(ThreadLocalRequestContext.module());
 
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeJson.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
-    factory(LabelsJson.Factory.class);
     factory(MergeUtil.Factory.class);
     factory(PatchScriptFactory.Factory.class);
     factory(PatchScriptFactoryForAutoFix.Factory.class);
     factory(ProjectState.Factory.class);
     factory(RevisionJson.Factory.class);
     factory(InboundEmailRejectionSender.Factory.class);
+    factory(ExternalUser.Factory.class);
     bind(PermissionCollection.Factory.class);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
     AccountDefaultDisplayName accountDefaultDisplayName =
@@ -308,8 +310,8 @@
     bind(SoySauce.class).annotatedWith(MailTemplates.class).toProvider(MailSoySauceProvider.class);
     bind(FromAddressGenerator.class).toProvider(FromAddressGeneratorProvider.class).in(SINGLETON);
     bind(Boolean.class)
-        .annotatedWith(EnableReverseDnsLookup.class)
-        .toProvider(EnableReverseDnsLookupProvider.class)
+        .annotatedWith(EnablePeerIPInReflogRecord.class)
+        .toProvider(EnablePeerIPInReflogRecordProvider.class)
         .in(SINGLETON);
 
     bind(PatchSetInfoFactory.class);
@@ -387,6 +389,7 @@
         .toInstance(SuggestReviewers.configListener());
     DynamicSet.setOf(binder(), ExternalIncludedIn.class);
     DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
+    DynamicSet.setOf(binder(), PluginPushOption.class);
     DynamicSet.setOf(binder(), PatchSetWebLink.class);
     DynamicSet.setOf(binder(), ParentWebLink.class);
     DynamicSet.setOf(binder(), FileWebLink.class);
@@ -413,6 +416,7 @@
     DynamicSet.setOf(binder(), ExceptionHook.class);
     DynamicSet.bind(binder(), ExceptionHook.class).to(ExceptionHookImpl.class);
     DynamicSet.setOf(binder(), MailSoyTemplateProvider.class);
+    DynamicSet.setOf(binder(), OnPostReview.class);
 
     DynamicMap.mapOf(binder(), MailFilter.class);
     bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
@@ -437,7 +441,6 @@
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeIsOperandFactory.class);
-    DynamicSet.setOf(binder(), ChangeAttributeFactory.class);
     DynamicSet.setOf(binder(), ChangePluginDefinedInfoFactory.class);
 
     install(new GitwebConfig.LegacyModule(cfg));
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index b5f09fd..0bc4380 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -315,11 +315,13 @@
     }
 
     @Override
-    public WebLinkInfo getFileWebLink(String projectName, String revision, String fileName) {
+    public WebLinkInfo getFileWebLink(
+        String projectName, String revision, String hash, String fileName) {
       if (file != null) {
         return link(
             file.replace("project", encode(projectName))
                 .replace("commit", encode(revision))
+                .replace("hash", encode(hash))
                 .replace("file", encode(fileName))
                 .toString());
       }
@@ -327,8 +329,10 @@
     }
 
     @Override
-    public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
+    public WebLinkInfo getPatchSetWebLink(
+        String projectName, String commit, String commitMessage, String branchName) {
       if (revision != null) {
+        // commitMessage and branchName are not needed, hence not used.
         return link(
             revision
                 .replace("project", encode(projectName))
@@ -339,9 +343,10 @@
     }
 
     @Override
-    public WebLinkInfo getParentWebLink(String projectName, String commit) {
+    public WebLinkInfo getParentWebLink(
+        String projectName, String commit, String commitMessage, String branchName) {
       // For Gitweb treat parent revision links the same as patch set links
-      return getPatchSetWebLink(projectName, commit);
+      return getPatchSetWebLink(projectName, commit, commitMessage, branchName);
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
index a9abd1e..2d0f9a5 100644
--- a/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -289,7 +289,7 @@
    */
   public Config getProjectPluginConfigWithInheritance(
       Project.NameKey projectName, String pluginName) throws NoSuchProjectException {
-    return getPluginConfig(projectName, pluginName).getWithInheritance(false);
+    return getPluginConfig(projectName, pluginName).getWithInheritance(/* merge= */ false);
   }
 
   /**
@@ -311,7 +311,7 @@
    */
   public Config getProjectPluginConfigWithInheritance(
       ProjectState projectState, String pluginName) {
-    return projectState.getConfig(pluginName + EXTENSION).getWithInheritance(false);
+    return projectState.getConfig(pluginName + EXTENSION).getWithInheritance(/* merge= */ false);
   }
 
   /**
@@ -336,7 +336,7 @@
    */
   public Config getProjectPluginConfigWithMergedInheritance(
       Project.NameKey projectName, String pluginName) throws NoSuchProjectException {
-    return getPluginConfig(projectName, pluginName).getWithInheritance(true);
+    return getPluginConfig(projectName, pluginName).getWithInheritance(/* merge= */ true);
   }
 
   /**
@@ -359,7 +359,7 @@
    */
   public Config getProjectPluginConfigWithMergedInheritance(
       ProjectState projectState, String pluginName) {
-    return projectState.getConfig(pluginName + EXTENSION).getWithInheritance(true);
+    return projectState.getConfig(pluginName + EXTENSION).getWithInheritance(/* merge= */ true);
   }
 
   private ProjectLevelConfig getPluginConfig(Project.NameKey projectName, String pluginName)
diff --git a/java/com/google/gerrit/server/config/SshClientImplementation.java b/java/com/google/gerrit/server/config/SshClientImplementation.java
new file mode 100644
index 0000000..5811e4d
--- /dev/null
+++ b/java/com/google/gerrit/server/config/SshClientImplementation.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Enums;
+import com.google.common.base.Strings;
+
+/* SSH implementation to use by JGit SSH client transport protocol. */
+public enum SshClientImplementation {
+  /** JCraft JSch implementation. */
+  JSCH,
+
+  /** Apache MINA implementation. */
+  APACHE;
+
+  private static final String ENV_VAR = "SSH_CLIENT_IMPLEMENTATION";
+  private static final String SYS_PROP = "gerrit.sshClientImplementation";
+
+  @VisibleForTesting
+  public static SshClientImplementation getFromEnvironment() {
+    String value = System.getenv(ENV_VAR);
+    if (Strings.isNullOrEmpty(value)) {
+      value = System.getProperty(SYS_PROP);
+    }
+    if (Strings.isNullOrEmpty(value)) {
+      return APACHE;
+    }
+    SshClientImplementation client =
+        Enums.getIfPresent(SshClientImplementation.class, value).orNull();
+    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
+      checkArgument(
+          client != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
+    } else {
+      checkArgument(
+          client != null,
+          "Invalid value for system property %s: %s",
+          SYS_PROP,
+          System.getProperty(SYS_PROP));
+    }
+    return client;
+  }
+
+  public boolean isMina() {
+    return this == APACHE;
+  }
+}
diff --git a/java/com/google/gerrit/server/data/BUILD b/java/com/google/gerrit/server/data/BUILD
index c3dc672..1aaab96 100644
--- a/java/com/google/gerrit/server/data/BUILD
+++ b/java/com/google/gerrit/server/data/BUILD
@@ -9,7 +9,6 @@
     deps = [
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
-        "//java/org/apache/commons/net",
         "//lib:gson",
     ],
 )
diff --git a/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java b/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
index ed4ea8a..2d2b207 100644
--- a/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
+++ b/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.data;
 
-import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 
 /**
- * Represents a {@link SubmitRequirement} that does not depend on Gerrit internal classes, to be
- * serialized
+ * Represents a {@link LegacySubmitRequirement} that does not depend on Gerrit internal classes, to
+ * be serialized
  */
 public class SubmitRequirementAttribute {
   public String type;
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index 2d5e708..d71f83e 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -21,14 +21,14 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
-import com.vladsch.flexmark.ast.Block;
 import com.vladsch.flexmark.ast.Heading;
-import com.vladsch.flexmark.ast.Node;
 import com.vladsch.flexmark.ast.util.TextCollectingVisitor;
 import com.vladsch.flexmark.html.HtmlRenderer;
 import com.vladsch.flexmark.parser.Parser;
 import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
-import com.vladsch.flexmark.util.options.MutableDataHolder;
+import com.vladsch.flexmark.util.ast.Block;
+import com.vladsch.flexmark.util.ast.Node;
+import com.vladsch.flexmark.util.data.MutableDataHolder;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
index 00f7ec1..1875b64 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.documentation;
 
 import com.vladsch.flexmark.ast.Heading;
-import com.vladsch.flexmark.ast.Node;
 import com.vladsch.flexmark.ext.anchorlink.AnchorLink;
 import com.vladsch.flexmark.ext.anchorlink.internal.AnchorLinkNodeRenderer;
 import com.vladsch.flexmark.html.HtmlRenderer;
@@ -28,8 +27,9 @@
 import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
 import com.vladsch.flexmark.profiles.pegdown.Extensions;
 import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
-import com.vladsch.flexmark.util.options.DataHolder;
-import com.vladsch.flexmark.util.options.MutableDataHolder;
+import com.vladsch.flexmark.util.ast.Node;
+import com.vladsch.flexmark.util.data.DataHolder;
+import com.vladsch.flexmark.util.data.MutableDataHolder;
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.Set;
@@ -119,7 +119,7 @@
 
     public static class Factory implements DelegatingNodeRendererFactory {
       @Override
-      public NodeRenderer create(final DataHolder options) {
+      public NodeRenderer apply(final DataHolder options) {
         return new HeadingNodeRenderer();
       }
 
diff --git a/java/com/google/gerrit/server/events/CommitReceivedEvent.java b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
index eb4d9ee..de355ea 100644
--- a/java/com/google/gerrit/server/events/CommitReceivedEvent.java
+++ b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import java.io.IOException;
@@ -29,6 +30,7 @@
   public ReceiveCommand command;
   public Project project;
   public String refName;
+  public ImmutableListMultimap<String, String> pushOptions;
   public Config repoConfig;
   public RevWalk revWalk;
   public RevCommit commit;
@@ -42,6 +44,7 @@
       ReceiveCommand command,
       Project project,
       String refName,
+      ImmutableListMultimap<String, String> pushOptions,
       Config repoConfig,
       ObjectReader reader,
       ObjectId commitId,
@@ -51,6 +54,7 @@
     this.command = command;
     this.project = project;
     this.refName = refName;
+    this.pushOptions = pushOptions;
     this.repoConfig = repoConfig;
     this.revWalk = new RevWalk(reader);
     this.commit = revWalk.parseCommit(commitId);
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 0c3c4fb..b648255 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -27,10 +27,10 @@
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 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.UserIdentity;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -222,7 +222,7 @@
   private void addSubmitRecordRequirements(SubmitRecord submitRecord, SubmitRecordAttribute sa) {
     if (submitRecord.requirements != null && !submitRecord.requirements.isEmpty()) {
       sa.requirements = new ArrayList<>();
-      for (SubmitRequirement req : submitRecord.requirements) {
+      for (LegacySubmitRequirement req : submitRecord.requirements) {
         SubmitRequirementAttribute re = new SubmitRequirementAttribute();
         re.fallbackText = req.fallbackText();
         re.type = req.type();
diff --git a/java/com/google/gerrit/server/events/EventGsonProvider.java b/java/com/google/gerrit/server/events/EventGsonProvider.java
index ab51518..bd784cf 100644
--- a/java/com/google/gerrit/server/events/EventGsonProvider.java
+++ b/java/com/google/gerrit/server/events/EventGsonProvider.java
@@ -15,9 +15,8 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.EntitiesAdapterFactory;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.change.ChangeKeyAdapter;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import com.google.inject.Provider;
@@ -26,10 +25,11 @@
   @Override
   public Gson get() {
     return new GsonBuilder()
+        .registerTypeAdapter(Event.class, new EventSerializer())
         .registerTypeAdapter(Event.class, new EventDeserializer())
         .registerTypeAdapter(Supplier.class, new SupplierSerializer())
         .registerTypeAdapter(Supplier.class, new SupplierDeserializer())
-        .registerTypeAdapter(Change.Key.class, new ChangeKeyAdapter())
+        .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
         .registerTypeHierarchyAdapter(Project.NameKey.class, new ProjectNameKeyAdapter())
         .create();
   }
diff --git a/java/com/google/gerrit/server/events/EventSerializer.java b/java/com/google/gerrit/server/events/EventSerializer.java
new file mode 100644
index 0000000..7322ef3
--- /dev/null
+++ b/java/com/google/gerrit/server/events/EventSerializer.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.events;
+
+import com.google.gerrit.common.UsedAt;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import java.lang.reflect.Type;
+
+@UsedAt(UsedAt.Project.PLUGIN_MULTI_SITE)
+class EventSerializer implements JsonSerializer<Event> {
+  @Override
+  public JsonElement serialize(Event src, Type typeOfSrc, JsonSerializationContext context) {
+    String type = src.getType();
+
+    Class<?> cls = EventTypes.getClass(type);
+    if (cls == null) {
+      throw new JsonParseException("Unknown event type: " + type);
+    }
+
+    return context.serialize(src, cls);
+  }
+}
diff --git a/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java b/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
new file mode 100644
index 0000000..f526935
--- /dev/null
+++ b/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.experiments;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * An implementation of {@link ExperimentFeatures} that uses gerrit.config to evaluate the status of
+ * the feature.
+ */
+@Singleton
+public class ConfigExperimentFeatures implements ExperimentFeatures {
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(ExperimentFeatures.class).to(ConfigExperimentFeatures.class);
+    }
+  }
+
+  private ImmutableSet<String> enabledExperimentFeatures;
+
+  @Inject
+  public ConfigExperimentFeatures(@GerritServerConfig Config gerritServerConfig) {
+    Set<String> enabledExperiments = new HashSet<>();
+    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "enabled"))
+        .forEach(enabledExperiments::add);
+    ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES.forEach(enabledExperiments::add);
+    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "disabled"))
+        .forEach(enabledExperiments::remove);
+    enabledExperimentFeatures = ImmutableSet.copyOf(enabledExperiments);
+  }
+
+  @Override
+  public boolean isFeatureEnabled(String featureFlag) {
+    return getEnabledExperimentFeatures().contains(featureFlag);
+  }
+
+  @Override
+  public ImmutableSet<String> getEnabledExperimentFeatures() {
+    return enabledExperimentFeatures;
+  }
+}
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeatures.java b/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
new file mode 100644
index 0000000..dc9148a
--- /dev/null
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.experiments;
+
+import com.google.common.collect.ImmutableSet;
+
+/**
+ * Features that can be enabled/disabled on Gerrit (e. g. experiments to research new behavior in
+ * the current release).
+ *
+ * <p>It may depend on the implementation if the result is decided on the per-request basis or not,
+ * so the outcomes should not be persisted in {@link com.google.inject.Singleton}.
+ */
+public interface ExperimentFeatures {
+
+  /**
+   * Given the name of the feature, returns if it is enabled on the Gerrit server.
+   *
+   * <p>Depending on the implementation, it can be more efficient than filtering the results of
+   * {@link ExperimentFeatures#getEnabledExperimentFeatures}.
+   *
+   * @param featureFlag the name of the feature to test.
+   * @return if the feature is enabled.
+   */
+  boolean isFeatureEnabled(String featureFlag);
+
+  /**
+   * Returns the names of the features that are enabled on Gerrit instance (either by default or via
+   * gerrit.config).
+   */
+  ImmutableSet<String> getEnabledExperimentFeatures();
+}
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
new file mode 100644
index 0000000..af49438
--- /dev/null
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.experiments;
+
+import com.google.common.collect.ImmutableSet;
+
+/** Constants for Gerrit {@link ExperimentFeatures} */
+public class ExperimentFeaturesConstants {
+
+  /** Features that are known experiments and can be referenced in the code. */
+  public static String UI_FEATURE_PATCHSET_COMMENTS = "UiFeature__patchset_comments";
+
+  /** Features, enabled by default in the current release. */
+  public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
+      ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS);
+}
diff --git a/java/com/google/gerrit/server/git/DelegateRepository.java b/java/com/google/gerrit/server/git/DelegateRepository.java
index 5ebe358..9ead038 100644
--- a/java/com/google/gerrit/server/git/DelegateRepository.java
+++ b/java/com/google/gerrit/server/git/DelegateRepository.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.gerrit.common.UsedAt;
 import java.io.File;
 import java.io.IOException;
@@ -30,6 +32,7 @@
 import org.eclipse.jgit.errors.RevisionSyntaxException;
 import org.eclipse.jgit.events.ListenerList;
 import org.eclipse.jgit.events.RepositoryEvent;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.BaseRepositoryBuilder;
 import org.eclipse.jgit.lib.ObjectDatabase;
@@ -395,4 +398,19 @@
       throws IOException {
     delegate.writeRebaseTodoFile(path, steps, append);
   }
+
+  /**
+   * Converts between ref storage formats.
+   *
+   * @param format the format to convert to, either "reftable" or "refdir"
+   * @param writeLogs whether to write reflogs
+   * @param backup whether to make a backup of the old data
+   * @throws IOException on I/O problems.
+   */
+  public void convertRefStorage(String format, boolean writeLogs, boolean backup)
+      throws IOException {
+    checkState(
+        delegate instanceof FileRepository, "Repository is not an instance of FileRepository!");
+    ((FileRepository) delegate).convertRefStorage(format, writeLogs, backup);
+  }
 }
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index 9e0f2ee..5bbe5e2 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -29,7 +29,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.receive.ReceivePackRefCache;
@@ -43,7 +42,6 @@
 import java.util.Set;
 import java.util.TreeSet;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
@@ -231,7 +229,7 @@
 
   private boolean isGroupFromExistingPatchSet(RevCommit commit, String group) throws IOException {
     ObjectId id = parseGroup(commit, group);
-    return id != null && !receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES).isEmpty();
+    return id != null && !receivePackRefCache.patchSetIdsFromObjectId(id).isEmpty();
   }
 
   private Set<String> resolveGroups(ObjectId forCommit, Collection<String> candidates)
@@ -273,17 +271,13 @@
   private Iterable<String> resolveGroup(ObjectId forCommit, String group) throws IOException {
     ObjectId id = parseGroup(forCommit, group);
     if (id != null) {
-      Ref ref =
-          Iterables.getFirst(receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES), null);
-      if (ref != null) {
-        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-        if (psId != null) {
-          List<String> groups = groupLookup.lookup(psId);
-          // Group for existing patch set may be missing, e.g. if group has not
-          // been migrated yet.
-          if (groups != null && !groups.isEmpty()) {
-            return groups;
-          }
+      PatchSet.Id psId = Iterables.getFirst(receivePackRefCache.patchSetIdsFromObjectId(id), null);
+      if (psId != null) {
+        List<String> groups = groupLookup.lookup(psId);
+        // Group for existing patch set may be missing, e.g. if group has not
+        // been migrated yet.
+        if (groups != null && !groups.isEmpty()) {
+          return groups;
         }
       }
     }
diff --git a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index d42ed25..10220d8 100644
--- a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -148,11 +148,11 @@
   @Override
   public Repository createRepository(Project.NameKey name)
       throws RepositoryNotFoundException, RepositoryCaseMismatchException, IOException {
-    Path path = getBasePath(name);
     if (isUnreasonableName(name)) {
       throw new RepositoryNotFoundException("Invalid name: " + name);
     }
 
+    Path path = getBasePath(name);
     File dir = FileKey.resolve(path.resolve(name.get()).toFile(), FS.DETECTED);
     if (dir != null) {
       // Already exists on disk, use the repository we found.
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index e0b101b..16a1ae6 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -488,6 +488,10 @@
   }
 
   public static String createConflictMessage(List<String> conflicts) {
+    if (conflicts.isEmpty()) {
+      return "";
+    }
+
     StringBuilder sb = new StringBuilder("merge conflict(s):");
     for (String c : conflicts) {
       sb.append('\n').append(c);
@@ -637,11 +641,11 @@
   }
 
   private static boolean isCodeReview(LabelId id) {
-    return "Code-Review".equalsIgnoreCase(id.get());
+    return LabelId.CODE_REVIEW.equalsIgnoreCase(id.get());
   }
 
   private static boolean isVerified(LabelId id) {
-    return "Verified".equalsIgnoreCase(id.get());
+    return LabelId.VERIFIED.equalsIgnoreCase(id.get());
   }
 
   private Iterable<PatchSetApproval> safeGetApprovals(ChangeNotes notes, PatchSet.Id psId) {
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index 40e2730..78cb013 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -39,6 +39,7 @@
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Constants;
@@ -189,8 +190,13 @@
                   @Override
                   public void run() {
                     try {
+                      // The stickyApprovalDiff is always empty here since this is not supported
+                      // for direct pushes.
                       MergedSender emailSender =
-                          mergedSenderFactory.create(ctx.getProject(), psId.changeId());
+                          mergedSenderFactory.create(
+                              ctx.getProject(),
+                              psId.changeId(),
+                              /* stickyApprovalDiff= */ Optional.empty());
                       emailSender.setFrom(ctx.getAccountId());
                       emailSender.setPatchSet(patchSet, info);
                       emailSender.setMessageId(
diff --git a/java/com/google/gerrit/server/git/TagSet.java b/java/com/google/gerrit/server/git/TagSet.java
index 43483bf..d6220a2 100644
--- a/java/com/google/gerrit/server/git/TagSet.java
+++ b/java/com/google/gerrit/server/git/TagSet.java
@@ -16,9 +16,9 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto;
@@ -37,6 +37,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectIdOwnerMap;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
@@ -44,6 +45,12 @@
 
 class TagSet {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final ImmutableSet<String> SKIPPABLE_REF_PREFIXES =
+      ImmutableSet.of(
+          RefNames.REFS_CHANGES,
+          RefNames.REFS_CACHE_AUTOMERGE,
+          RefNames.REFS_DRAFT_COMMENTS,
+          RefNames.REFS_STARRED_CHANGES);
 
   private final Project.NameKey projectName;
   private final Map<String, CachedRef> refs;
@@ -179,7 +186,9 @@
 
     try (TagWalk rw = new TagWalk(git)) {
       rw.setRetainBody(false);
-      for (Ref ref : git.getRefDatabase().getRefs()) {
+      for (Ref ref :
+          git.getRefDatabase()
+              .getRefsByPrefixWithExclusions(RefDatabase.ALL, SKIPPABLE_REF_PREFIXES)) {
         if (skip(ref)) {
           continue;
 
@@ -365,9 +374,7 @@
   static boolean skip(Ref ref) {
     return ref.isSymbolic()
         || ref.getObjectId() == null
-        || PatchSet.isChangeRef(ref.getName())
-        || RefNames.isNoteDbMetaRef(ref.getName())
-        || ref.getName().startsWith(RefNames.REFS_CACHE_AUTOMERGE);
+        || SKIPPABLE_REF_PREFIXES.stream().anyMatch(p -> ref.getName().startsWith(p));
   }
 
   private static boolean isTag(Ref ref) {
diff --git a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
index 55261223..f680b7b 100644
--- a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
+++ b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
@@ -19,6 +19,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
@@ -109,12 +110,13 @@
       ObjectReader objectReader,
       ReceiveCommand cmd,
       RevCommit commit,
+      ImmutableListMultimap<String, String> pushOptions,
       boolean isMerged,
       NoteMap rejectCommits,
       @Nullable Change change)
       throws IOException {
     return validateCommit(
-        repository, objectReader, cmd, commit, isMerged, rejectCommits, change, false);
+        repository, objectReader, cmd, commit, pushOptions, isMerged, rejectCommits, change, false);
   }
 
   /**
@@ -134,6 +136,7 @@
       ObjectReader objectReader,
       ReceiveCommand cmd,
       RevCommit commit,
+      ImmutableListMultimap<String, String> pushOptions,
       boolean isMerged,
       NoteMap rejectCommits,
       @Nullable Change change,
@@ -146,6 +149,7 @@
               cmd,
               project,
               branch.branch(),
+              pushOptions,
               new Config(repository.getConfig()),
               objectReader,
               commit,
diff --git a/java/com/google/gerrit/server/git/receive/PluginPushOption.java b/java/com/google/gerrit/server/git/receive/PluginPushOption.java
new file mode 100644
index 0000000..788df70
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/PluginPushOption.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.git.receive;
+
+/**
+ * Push option that can be specified on push.
+ *
+ * <p>On push the option has to be specified as {@code -o <pluginName>~<name>=<value>}, or if a
+ * value is not required as {@code -o <pluginName>~<name>}.
+ */
+public interface PluginPushOption {
+  /** The name of the push option. */
+  public String getName();
+
+  /** The description of the push option. */
+  public String getDescription();
+}
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 324be5e..cca8681 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -17,10 +17,12 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
 import static com.google.gerrit.entities.RefNames.isConfigRef;
+import static com.google.gerrit.entities.RefNames.isRefsUsersSelf;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.change.HashtagsUtil.cleanupHashtag;
 import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN;
@@ -47,6 +49,7 @@
 import com.google.common.collect.BiMap;
 import com.google.common.collect.HashBiMap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
@@ -112,6 +115,7 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.change.SetPrivateOp;
+import com.google.gerrit.server.change.SetTopicOp;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfig;
@@ -144,6 +148,7 @@
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -179,7 +184,6 @@
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -210,6 +214,7 @@
 import java.util.concurrent.Future;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -325,6 +330,7 @@
   private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
   private final CreateRefControl createRefControl;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+  private final DynamicSet<PluginPushOption> pluginPushOptions;
   private final PluginSetContext<ReceivePackInitializer> initializers;
   private final MergedByPushOp.Factory mergedByPushOpFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
@@ -344,12 +350,14 @@
   private final RequestScopePropagator requestScopePropagator;
   private final Sequences seq;
   private final SetHashtagsOp.Factory hashtagsFactory;
-  private final SubmissionListener superprojectUpdateSubmissionListener;
+  private final SetTopicOp.Factory setTopicFactory;
+  private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners;
   private final TagCache tagCache;
   private final ProjectConfig.Factory projectConfigFactory;
   private final SetPrivateOp.Factory setPrivateOpFactory;
   private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
   private final DynamicItem<UrlFormatter> urlFormatter;
+  private final AutoMerger autoMerger;
 
   // Assisted injected fields.
   private final ProjectState projectState;
@@ -406,6 +414,7 @@
       CreateGroupPermissionSyncer createGroupPermissionSyncer,
       CreateRefControl createRefControl,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      DynamicSet<PluginPushOption> pluginPushOptions,
       PluginSetContext<ReceivePackInitializer> initializers,
       PluginSetContext<CommentValidator> commentValidators,
       MergedByPushOp.Factory mergedByPushOpFactory,
@@ -426,11 +435,14 @@
       RequestScopePropagator requestScopePropagator,
       Sequences seq,
       SetHashtagsOp.Factory hashtagsFactory,
-      @SuperprojectUpdateOnSubmission SubmissionListener superprojectUpdateSubmissionListener,
+      SetTopicOp.Factory setTopicFactory,
+      @SuperprojectUpdateOnSubmission
+          ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners,
       TagCache tagCache,
       SetPrivateOp.Factory setPrivateOpFactory,
       ReplyAttentionSetUpdates replyAttentionSetUpdates,
       DynamicItem<UrlFormatter> urlFormatter,
+      AutoMerger autoMerger,
       @Assisted ProjectState projectState,
       @Assisted IdentifiedUser user,
       @Assisted ReceivePack rp,
@@ -452,6 +464,7 @@
     this.createGroupPermissionSyncer = createGroupPermissionSyncer;
     this.editUtil = editUtil;
     this.hashtagsFactory = hashtagsFactory;
+    this.setTopicFactory = setTopicFactory;
     this.indexer = indexer;
     this.initializers = initializers;
     this.mergeOpProvider = mergeOpProvider;
@@ -462,6 +475,7 @@
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.permissionBackend = permissionBackend;
     this.pluginConfigEntries = pluginConfigEntries;
+    this.pluginPushOptions = pluginPushOptions;
     this.projectCache = projectCache;
     this.psUtil = psUtil;
     this.performanceLoggers = performanceLoggers;
@@ -474,12 +488,13 @@
     this.retryHelper = retryHelper;
     this.requestScopePropagator = requestScopePropagator;
     this.seq = seq;
-    this.superprojectUpdateSubmissionListener = superprojectUpdateSubmissionListener;
+    this.superprojectUpdateSubmissionListeners = superprojectUpdateSubmissionListeners;
     this.tagCache = tagCache;
     this.projectConfigFactory = projectConfigFactory;
     this.setPrivateOpFactory = setPrivateOpFactory;
     this.replyAttentionSetUpdates = replyAttentionSetUpdates;
     this.urlFormatter = urlFormatter;
+    this.autoMerger = autoMerger;
 
     // Assisted injected fields.
     this.projectState = projectState;
@@ -624,7 +639,7 @@
   private void processCommandsUnsafe(
       Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
     logger.atFine().log("Calling user: %s", user.getLoggableName());
-    logger.atFine().log("Groups: %s", user.getEffectiveGroups().getKnownGroups());
+    logger.atFine().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
 
     if (!projectState.getProject().getState().permitsWrite()) {
       for (ReceiveCommand cmd : commands) {
@@ -742,7 +757,7 @@
         logger.atFine().log("Added %d additional ref updates", added);
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(false, superprojectUpdateSubmissionListener);
+            new SubmissionExecutor(false, superprojectUpdateSubmissionListeners);
 
         submissionExecutor.execute(ImmutableList.of(bu));
 
@@ -1076,7 +1091,7 @@
   private ReceiveCommand wrapReceiveCommand(ReceiveCommand cmd, Task progress) {
     String refname = cmd.getRefName();
 
-    if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(cmd.getRefName())) {
+    if (isRefsUsersSelf(cmd.getRefName(), projectState.isAllUsers())) {
       refname = RefNames.refsUsers(user.getAccountId());
       logger.atFine().log("Swapping out command for %s to %s", RefNames.REFS_USERS_SELF, refname);
     }
@@ -1783,8 +1798,13 @@
       String ref;
       magicBranch.cmdLineParser = optionParserFactory.create(magicBranch);
 
+      // Filter out plugin push options, as the parser would reject them as unknown.
+      ImmutableListMultimap<String, String> pushOptionsToParse =
+          pushOptions.entries().stream()
+              .filter(e -> !isPluginPushOption(e.getKey()))
+              .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue()));
       try {
-        ref = magicBranch.parse(pushOptions);
+        ref = magicBranch.parse(pushOptionsToParse);
       } catch (CmdLineException e) {
         if (!magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
           logger.atFine().log("Invalid branch syntax");
@@ -1803,6 +1823,20 @@
         StringWriter w = new StringWriter();
         w.write("\nHelp for refs/for/branch:\n\n");
         magicBranch.cmdLineParser.printUsage(w, null);
+
+        String pluginPushOptionsHelp =
+            StreamSupport.stream(pluginPushOptions.entries().spliterator(), /* parallel= */ false)
+                .map(
+                    e ->
+                        String.format(
+                            "-o %s~%s: %s",
+                            e.getPluginName(), e.get().getName(), e.get().getDescription()))
+                .sorted()
+                .collect(joining("\n"));
+        if (!pluginPushOptionsHelp.isEmpty()) {
+          w.write("\nPlugin push options:\n" + pluginPushOptionsHelp);
+        }
+
         addMessage(w.toString());
         reject(cmd, "see help");
         return;
@@ -1967,6 +2001,11 @@
     }
   }
 
+  private boolean isPluginPushOption(String pushOptionName) {
+    return StreamSupport.stream(pluginPushOptions.entries().spliterator(), /* parallel= */ false)
+        .anyMatch(e -> pushOptionName.equals(e.getPluginName() + "~" + e.get().getName()));
+  }
+
   // Validate that the new commits are connected with the target
   // branch.  If they aren't, we want to abort. We do this check by
   // looking to see if we can compute a merge base between the new
@@ -2145,15 +2184,15 @@
           receivePack.getRevWalk().parseBody(c);
           String name = c.name();
           groupCollector.visit(c);
-          Collection<Ref> existingRefs =
-              receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES);
+          Collection<PatchSet.Id> existingPatchSets =
+              receivePackRefCache.patchSetIdsFromObjectId(c);
 
           if (rejectImplicitMerges) {
             Collections.addAll(mergedParents, c.getParents());
             mergedParents.remove(c);
           }
 
-          boolean commitAlreadyTracked = !existingRefs.isEmpty();
+          boolean commitAlreadyTracked = !existingPatchSets.isEmpty();
           if (commitAlreadyTracked) {
             alreadyTracked++;
             // Corner cases where an existing commit might need a new group:
@@ -2169,9 +2208,7 @@
             //      A's group.
             // C) Commit is a PatchSet of a pre-existing change uploaded with a
             //    different target branch.
-            existingRefs.stream()
-                .map(r -> PatchSet.Id.fromRef(r.getName()))
-                .filter(Objects::nonNull)
+            existingPatchSets.stream()
                 .forEach(i -> updateGroups.add(new UpdateGroupsRequest(i, c)));
             if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
               continue;
@@ -2216,6 +2253,7 @@
                   receivePack.getRevWalk().getObjectReader(),
                   magicBranch.cmd,
                   c,
+                  ImmutableListMultimap.copyOf(pushOptions),
                   magicBranch.merged,
                   rejectCommits,
                   null);
@@ -2312,8 +2350,7 @@
 
             // In case the change look up from the index failed,
             // double check against the existing refs
-            if (foundInExistingRef(
-                receivePackRefCache.tipsFromObjectId(p.commit, RefNames.REFS_CHANGES))) {
+            if (foundInExistingPatchSets(receivePackRefCache.patchSetIdsFromObjectId(p.commit))) {
               if (pending.size() == 1) {
                 reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
                 return Collections.emptyList();
@@ -2361,11 +2398,10 @@
     }
   }
 
-  private boolean foundInExistingRef(Collection<Ref> existingRefs) {
-    try (TraceTimer traceTimer = newTimer("foundInExistingRef")) {
-      for (Ref ref : existingRefs) {
-        ChangeNotes notes =
-            notesFactory.create(project.getNameKey(), Change.Id.fromRef(ref.getName()));
+  private boolean foundInExistingPatchSets(Collection<PatchSet.Id> existingPatchSets) {
+    try (TraceTimer traceTimer = newTimer("foundInExistingPatchSet")) {
+      for (PatchSet.Id psId : existingPatchSets) {
+        ChangeNotes notes = notesFactory.create(project.getNameKey(), psId.changeId());
         Change change = notes.getChange();
         if (change.getDest().equals(magicBranch.dest)) {
           logger.atFine().log("Found change %s from existing refs.", change.getKey());
@@ -2598,7 +2634,7 @@
                     .setFireEvent(false));
           }
           if (!Strings.isNullOrEmpty(magicBranch.topic)) {
-            bu.addOp(changeId, new SetTopicOp(magicBranch.topic));
+            bu.addOp(changeId, setTopicFactory.create(magicBranch.topic));
           }
           bu.addOp(
               changeId,
@@ -2837,15 +2873,15 @@
           return false;
         }
 
-        List<Ref> existingChangesWithSameCommit =
-            receivePackRefCache.tipsFromObjectId(newCommit, RefNames.REFS_CHANGES);
-        if (!existingChangesWithSameCommit.isEmpty()) {
+        List<PatchSet.Id> existingPatchSetsWithSameCommit =
+            receivePackRefCache.patchSetIdsFromObjectId(newCommit);
+        if (!existingPatchSetsWithSameCommit.isEmpty()) {
           // TODO(hiesel, hanwen): Remove this check entirely when Gerrit requires change IDs
           //  without the option to turn that off.
           reject(
               inputCommand,
               "commit already exists (in the project): "
-                  + existingChangesWithSameCommit.get(0).getName());
+                  + existingPatchSetsWithSameCommit.get(0).toRefName());
           return false;
         }
 
@@ -3031,6 +3067,25 @@
         if (progress != null) {
           bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
         }
+        bu.addRepoOnlyOp(
+            new RepoOnlyOp() {
+              @Override
+              public void updateRepo(RepoContext ctx) throws Exception {
+                // Create auto merge ref if the new patch set is a merge commit. This is only
+                // required for new patch sets on existing changes as these do not go through
+                // PatchSetInserter. New changes pushed via git go through ChangeInserter and have
+                // their auto merge commits created there.
+                Optional<ReceiveCommand> autoMerge =
+                    autoMerger.createAutoMergeCommitIfNecessary(
+                        ctx.getRepoView(),
+                        ctx.getRevWalk(),
+                        ctx.getInserter(),
+                        ctx.getRevWalk().parseCommit(newCommitId));
+                if (autoMerge.isPresent()) {
+                  ctx.addRefUpdate(autoMerge.get());
+                }
+              }
+            });
       }
     }
 
@@ -3224,13 +3279,21 @@
                     "more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
             return;
           }
-          if (!receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES).isEmpty()) {
+          if (!receivePackRefCache.patchSetIdsFromObjectId(c).isEmpty()) {
             continue;
           }
 
           BranchCommitValidator.Result validationResult =
               validator.validateCommit(
-                  repo, walk.getObjectReader(), cmd, c, false, rejectCommits, null, skipValidation);
+                  repo,
+                  walk.getObjectReader(),
+                  cmd,
+                  c,
+                  ImmutableListMultimap.copyOf(pushOptions),
+                  false,
+                  rejectCommits,
+                  null,
+                  skipValidation);
           messages.addAll(validationResult.messages());
           if (!validationResult.isValid()) {
             break;
@@ -3293,12 +3356,8 @@
 
                       // Check if change refs point to this commit. Usually there are 0-1 change
                       // refs pointing to this commit.
-                      for (Ref ref :
-                          receivePackRefCache.tipsFromObjectId(c.copy(), RefNames.REFS_CHANGES)) {
-                        if (!PatchSet.isChangeRef(ref.getName())) {
-                          continue;
-                        }
-                        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+                      for (PatchSet.Id psId :
+                          receivePackRefCache.patchSetIdsFromObjectId(c.copy())) {
                         Optional<ChangeNotes> notes = getChangeNotes(psId.changeId());
                         if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) {
                           if (submissionId == null) {
@@ -3474,19 +3533,4 @@
     b.append(")\n");
     return b.toString();
   }
-
-  private static class SetTopicOp implements BatchUpdateOp {
-
-    private final String topic;
-
-    public SetTopicOp(String topic) {
-      this.topic = topic;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws ValidationException {
-      ctx.getUpdate(ctx.getChange().currentPatchSetId()).setTopic(topic);
-      return true;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
index 376ab2d..8568810 100644
--- a/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
+++ b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
@@ -21,9 +21,11 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
 import java.io.IOException;
 import java.util.Map;
+import java.util.Objects;
 import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -58,8 +60,8 @@
     return new WithAdvertisedRefs(allRefsSupplier);
   }
 
-  /** Returns a list of refs whose name starts with {@code prefix} that point to {@code id}. */
-  ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix) throws IOException;
+  /** Returns a list of {@link com.google.gerrit.entities.PatchSet.Id}s that point to {@code id}. */
+  ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) throws IOException;
 
   /** Returns all refs whose name starts with {@code prefix}. */
   ImmutableList<Ref> byPrefix(String prefix) throws IOException;
@@ -76,10 +78,10 @@
     }
 
     @Override
-    public ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix)
-        throws IOException {
+    public ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) throws IOException {
       return delegate.getTipsWithSha1(id).stream()
-          .filter(r -> prefix == null || r.getName().startsWith(prefix))
+          .map(r -> PatchSet.Id.fromRef(r.getName()))
+          .filter(Objects::nonNull)
           .collect(toImmutableList());
     }
 
@@ -113,10 +115,11 @@
     }
 
     @Override
-    public ImmutableList<Ref> tipsFromObjectId(ObjectId id, String prefix) {
+    public ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) {
       lazilyInitRefMaps();
       return refsByObjectId.get(id).stream()
-          .filter(r -> prefix == null || r.getName().startsWith(prefix))
+          .map(r -> PatchSet.Id.fromRef(r.getName()))
+          .filter(Objects::nonNull)
           .collect(toImmutableList());
     }
 
diff --git a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
index d507531..6e640f3 100644
--- a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
+++ b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
@@ -34,6 +35,8 @@
  * issues. Note that autogenerated change messages are not subject to validation.
  */
 public class CommentCumulativeSizeValidator implements CommentValidator {
+  public static final int DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT = 3 << 20;
+
   private final int maxCumulativeSize;
   private final ChangeNotes.Factory notesFactory;
 
@@ -41,7 +44,9 @@
   CommentCumulativeSizeValidator(
       @GerritServerConfig Config serverConfig, ChangeNotes.Factory notesFactory) {
     this.notesFactory = notesFactory;
-    maxCumulativeSize = serverConfig.getInt("change", "cumulativeCommentSizeLimit", 3 << 20);
+    maxCumulativeSize =
+        serverConfig.getInt(
+            "change", "cumulativeCommentSizeLimit", DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT);
   }
 
   @Override
@@ -55,7 +60,13 @@
                     notes.getRobotComments().values().stream())
                 .mapToInt(Comment::getApproximateSize)
                 .sum()
-            + notes.getChangeMessages().stream().mapToInt(cm -> cm.getMessage().length()).sum();
+            + notes.getChangeMessages().stream()
+                // Auto-generated change messages are not counted for the limit. This method is not
+                // called when those change messages are created, but we should also skip them when
+                // counting the size for unrelated messages.
+                .filter(cm -> !ChangeMessagesUtil.isAutogenerated(cm.getTag()))
+                .mapToInt(cm -> cm.getMessage().length())
+                .sum();
     int newCumulativeSize =
         comments.stream().mapToInt(CommentForValidation::getApproximateSize).sum();
     ImmutableList.Builder<CommentValidationFailure> failures = ImmutableList.builder();
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index f84b696..870667b 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -56,11 +56,11 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.ssh.HostKey;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import com.jcraft.jsch.HostKey;
 import java.io.IOException;
 import java.net.MalformedURLException;
 import java.net.URL;
@@ -234,7 +234,7 @@
     List<CommitValidationMessage> messages = new ArrayList<>();
     try {
       for (CommitValidationListener commitValidator : validators) {
-        try (TraceTimer traceTimer =
+        try (TraceTimer ignored =
             TraceContext.newTimer(
                 "Running CommitValidationListener",
                 Metadata.builder()
@@ -336,7 +336,7 @@
       } else if (idList.size() > 1) {
         throw new CommitValidationException(MULTIPLE_CHANGE_ID_MSG, messages);
       } else {
-        String v = idList.get(idList.size() - 1).trim();
+        String v = idList.get(0).trim();
         // Reject Change-Ids with wrong format and invalid placeholder ID from
         // Egit (I0000000000000000000000000000000000000000).
         if (!CHANGE_ID.matcher(v).matches() || v.matches("^I00*$")) {
@@ -458,10 +458,11 @@
 
     private long countChangedFiles(CommitReceivedEvent receiveEvent) throws IOException {
       try (Repository repository = repoManager.openRepository(receiveEvent.project.getNameKey());
-          RevWalk revWalk = new RevWalk(repository);
           DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-        diffFormatter.setReader(revWalk.getObjectReader(), repository.getConfig());
-        diffFormatter.setDetectRenames(true);
+        diffFormatter.setRepository(repository);
+        // Do not detect renames; that would require reading file contents, which is slow for large
+        // files.
+        diffFormatter.setDetectRenames(false);
         // For merge commits, i.e. >1 parents, we use parent #0 by convention.
         List<DiffEntry> diffEntries =
             diffFormatter.scan(
@@ -562,7 +563,7 @@
 
   /** Execute commit validation plug-ins */
   public static class PluginCommitValidationListener implements CommitValidationListener {
-    private boolean skipValidation;
+    private final boolean skipValidation;
     private final PluginSetContext<CommitValidationListener> commitValidationListeners;
 
     public PluginCommitValidationListener(
@@ -604,7 +605,8 @@
 
     @Override
     public boolean shouldValidateAllCommits() {
-      return commitValidationListeners.stream().anyMatch(v -> v.shouldValidateAllCommits());
+      return commitValidationListeners.stream()
+          .anyMatch(CommitValidationListener::shouldValidateAllCommits);
     }
   }
 
diff --git a/java/com/google/gerrit/server/group/GroupResolver.java b/java/com/google/gerrit/server/group/GroupResolver.java
index 50ec893..546614c 100644
--- a/java/com/google/gerrit/server/group/GroupResolver.java
+++ b/java/com/google/gerrit/server/group/GroupResolver.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
diff --git a/java/com/google/gerrit/server/group/InternalGroupDescription.java b/java/com/google/gerrit/server/group/InternalGroupDescription.java
index 740557a..62ebcfe 100644
--- a/java/com/google/gerrit/server/group/InternalGroupDescription.java
+++ b/java/com/google/gerrit/server/group/InternalGroupDescription.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.InternalGroup;
 import java.sql.Timestamp;
 
 public class InternalGroupDescription implements GroupDescription.Internal {
diff --git a/java/com/google/gerrit/server/group/SystemGroupBackend.java b/java/com/google/gerrit/server/group/SystemGroupBackend.java
index b5ccb18..5d50d22 100644
--- a/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -24,6 +24,8 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.ExternalUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
@@ -90,6 +92,7 @@
   private final SortedMap<String, GroupReference> namesToGroups;
   private final ImmutableSet<String> names;
   private final ImmutableMap<AccountGroup.UUID, GroupReference> uuids;
+  private final ImmutableSet<AccountGroup.UUID> externalUserMemberships;
 
   @Inject
   @VisibleForTesting
@@ -114,6 +117,10 @@
         ImmutableSet.copyOf(
             namesToGroups.values().stream().map(GroupReference::getName).collect(toSet()));
     uuids = u.build();
+    externalUserMemberships =
+        cfg.getBoolean("groups", null, "includeExternalUsersInRegisteredUsersGroup", true)
+            ? ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS)
+            : ImmutableSet.of(ANONYMOUS_USERS);
   }
 
   public GroupReference getGroup(AccountGroup.UUID uuid) {
@@ -182,8 +189,14 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
-    return new ListGroupMembership(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS));
+  public GroupMembership membershipsOf(CurrentUser user) {
+    if (user instanceof ExternalUser) {
+      return new ListGroupMembership(externalUserMemberships);
+    }
+    if (user instanceof IdentifiedUser) {
+      return new ListGroupMembership(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS));
+    }
+    return new ListGroupMembership(ImmutableSet.of(ANONYMOUS_USERS));
   }
 
   public static class NameCheck implements StartupCheck {
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index 60a1e3e..b2d1849b 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -24,14 +24,15 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -156,7 +157,7 @@
       Project.NameKey projectName,
       Repository repository,
       AccountGroup.UUID groupUuid,
-      ObjectId groupRefObjectId)
+      @Nullable ObjectId groupRefObjectId)
       throws IOException, ConfigInvalidException {
     GroupConfig groupConfig = new GroupConfig(groupUuid);
     if (groupRefObjectId == null) {
diff --git a/java/com/google/gerrit/server/group/db/GroupConfigCommitMessage.java b/java/com/google/gerrit/server/group/db/GroupConfigCommitMessage.java
index 5627154..77c284a 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfigCommitMessage.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfigCommitMessage.java
@@ -17,7 +17,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
-import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.entities.InternalGroup;
 import java.util.Optional;
 import java.util.Set;
 import java.util.StringJoiner;
diff --git a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
index 81f5c7e..be56344 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
@@ -16,7 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.entities.InternalGroup;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
diff --git a/java/com/google/gerrit/server/group/db/Groups.java b/java/com/google/gerrit/server/group/db/Groups.java
index 90a5a1f..30e37cb 100644
--- a/java/com/google/gerrit/server/group/db/Groups.java
+++ b/java/com/google/gerrit/server/group/db/Groups.java
@@ -17,13 +17,14 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroupByIdAudit;
 import com.google.gerrit.entities.AccountGroupMemberAudit;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -80,6 +81,23 @@
   }
 
   /**
+   * Returns the {@code InternalGroup} for the specified UUID and groupRefObjectId
+   *
+   * @param groupUuid the UUID of the group
+   * @param groupRefObjectId the ref revision of this group
+   * @return the found {@code InternalGroup} if it exists, or else an empty {@code Optional}
+   * @throws IOException if the group couldn't be retrieved from NoteDb
+   * @throws ConfigInvalidException if the group couldn't be retrieved from NoteDb
+   */
+  public Optional<InternalGroup> getGroup(
+      AccountGroup.UUID groupUuid, @Nullable ObjectId groupRefObjectId)
+      throws IOException, ConfigInvalidException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+      return getGroupFromNoteDb(allUsersName, allUsersRepo, groupUuid, groupRefObjectId);
+    }
+  }
+
+  /**
    * Loads an internal group from NoteDb using the group UUID. This method returns the latest state
    * of the internal group.
    */
@@ -97,7 +115,7 @@
       AllUsersName allUsersName,
       Repository allUsersRepository,
       AccountGroup.UUID uuid,
-      ObjectId groupRefObjectId)
+      @Nullable ObjectId groupRefObjectId)
       throws IOException, ConfigInvalidException {
     GroupConfig groupConfig =
         GroupConfig.loadForGroup(allUsersName, allUsersRepository, uuid, groupRefObjectId);
diff --git a/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java
index 8a6cd94..732712e 100644
--- a/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java
@@ -19,13 +19,13 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
index 35f5dea..01ee811 100644
--- a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
@@ -25,11 +25,11 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index 4ec5c36..02d55eb 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.NoSuchGroupException;
@@ -39,7 +40,6 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.GroupAuditService;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
diff --git a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java b/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
index 9e6539a..5c7408c 100644
--- a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
+++ b/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
@@ -101,7 +101,7 @@
    *
    * <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.server.group.InternalGroup#getCreatedOn()
+   * 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
diff --git a/java/com/google/gerrit/server/group/testing/BUILD b/java/com/google/gerrit/server/group/testing/BUILD
index fd61dff..77bb777 100644
--- a/java/com/google/gerrit/server/group/testing/BUILD
+++ b/java/com/google/gerrit/server/group/testing/BUILD
@@ -7,7 +7,6 @@
     testonly = True,
     srcs = glob(["*.java"]),
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//lib:guava",
diff --git a/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java b/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
index 8f20e92..8a1221e 100644
--- a/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
+++ b/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
@@ -23,7 +23,7 @@
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.entities.InternalGroup;
 import java.sql.Timestamp;
 import org.eclipse.jgit.lib.ObjectId;
 
diff --git a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
index 35f18a2..601ac59 100644
--- a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
+++ b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.project.ProjectState;
@@ -122,7 +122,10 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
+  public GroupMembership membershipsOf(CurrentUser user) {
+    if (!user.isIdentifiedUser()) {
+      return GroupMembership.EMPTY;
+    }
     return memberships.getOrDefault(user.getAccountId(), GroupMembership.EMPTY);
   }
 
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index 9e3d91c..ee8dfc8 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.group.GroupField;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gerrit.server.query.change.GroupBackedUser;
 import java.io.IOException;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -107,7 +107,7 @@
     if (user.isIdentifiedUser()) {
       return user.getAccountId().toString();
     }
-    if (user instanceof SingleGroupUser) {
+    if (user instanceof GroupBackedUser) {
       return "group:" + user.getEffectiveGroups().getKnownGroups().iterator().next().toString();
     }
     return user.toString();
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index d6b8ef9..f176c38 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -164,6 +164,8 @@
           "Failed %s/%s changes (%s%%); not marking new index as ready",
           nFailed, nTotal, Math.round(pctFailed));
       ok.set(false);
+    } else if (nFailed > 0) {
+      logger.atWarning().log("Failed %s/%s changes", nFailed, nTotal);
     }
     return Result.create(sw, ok.get(), nDone, nFailed);
   }
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index ef538cb..ce748d1 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -47,17 +47,16 @@
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
 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.index.FieldDef;
-import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.proto.Protos;
@@ -66,9 +65,7 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.notedb.RobotCommentNotes;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
@@ -152,6 +149,12 @@
   public static final FieldDef<ChangeData, Timestamp> UPDATED =
       timestamp("updated2").stored().build(changeGetter(Change::getLastUpdatedOn));
 
+  /** When this change was merged, time since January 1, 1970. */
+  public static final FieldDef<ChangeData, Timestamp> MERGED_ON =
+      timestamp(ChangeQueryBuilder.FIELD_MERGED_ON)
+          .stored()
+          .build(cd -> cd.getMergedOn().orElse(null));
+
   /** List of full file paths modified in the current patch set. */
   public static final FieldDef<ChangeData, Iterable<String>> PATH =
       // Named for backwards compatibility.
@@ -812,7 +815,7 @@
               });
 
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
-      SubmitRuleOptions.builder().allowClosed(true).build();
+      SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
 
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
       SubmitRuleOptions.builder().build();
@@ -856,12 +859,12 @@
       }
       if (rec.requirements != null) {
         this.requirements = new ArrayList<>(rec.requirements.size());
-        for (SubmitRequirement requirement : rec.requirements) {
+        for (LegacySubmitRequirement requirement : rec.requirements) {
           StoredRequirement sr = new StoredRequirement();
           sr.type = requirement.type();
           sr.fallbackText = requirement.fallbackText();
           // For backwards compatibility, write an empty map to the index.
-          // This is required, because the SubmitRequirement AutoValue can't
+          // This is required, because the LegacySubmitRequirement AutoValue can't
           // handle null in the old code.
           // TODO(hiesel): Remove once we have rolled out the new code
           //  and waited long enough to not need to roll back.
@@ -888,8 +891,8 @@
       if (requirements != null) {
         rec.requirements = new ArrayList<>(requirements.size());
         for (StoredRequirement req : requirements) {
-          SubmitRequirement sr =
-              SubmitRequirement.builder()
+          LegacySubmitRequirement sr =
+              LegacySubmitRequirement.builder()
                   .setType(req.type)
                   .setFallbackText(req.fallbackText)
                   .build();
@@ -914,11 +917,6 @@
   public static void parseSubmitRecords(
       Collection<String> values, SubmitRuleOptions opts, ChangeData out) {
     List<SubmitRecord> records = parseSubmitRecords(values);
-    if (records.isEmpty()) {
-      // Assume no values means the field is not in the index;
-      // SubmitRuleEvaluator ensures the list is non-empty.
-      return;
-    }
     out.setSubmitRecords(opts, records);
   }
 
@@ -976,27 +974,9 @@
           .buildRepeatable(
               cd -> {
                 List<byte[]> result = new ArrayList<>();
-                Project.NameKey project = cd.change().getProject();
-
-                cd.editRefs()
-                    .values()
-                    .forEach(r -> result.add(RefState.of(r).toByteArray(project)));
-                cd.starRefs()
-                    .values()
-                    .forEach(r -> result.add(RefState.of(r.ref()).toByteArray(allUsers(cd))));
-
-                ChangeNotes notes = cd.notes();
-                result.add(
-                    RefState.create(notes.getRefName(), notes.getMetaId()).toByteArray(project));
-                notes.getRobotComments(); // Force loading robot comments.
-                RobotCommentNotes robotNotes = notes.getRobotCommentNotes();
-                result.add(
-                    RefState.create(robotNotes.getRefName(), robotNotes.getMetaId())
-                        .toByteArray(project));
-                cd.draftRefs()
-                    .values()
-                    .forEach(r -> result.add(RefState.of(r).toByteArray(allUsers(cd))));
-
+                cd.getRefStates()
+                    .entries()
+                    .forEach(e -> result.add(e.getValue().toByteArray(e.getKey())));
                 return result;
               });
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 928f21c..969b071 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -130,9 +130,14 @@
           .build();
 
   /** Added new fields {@link ChangeField#MERGE} */
+  @Deprecated
   static final Schema<ChangeData> V60 =
       new Schema.Builder<ChangeData>().add(V59).add(ChangeField.MERGE).build();
 
+  /** Added new field {@link ChangeField#MERGED_ON} */
+  static final Schema<ChangeData> V61 =
+      new Schema.Builder<ChangeData>().add(V60).add(ChangeField.MERGED_ON).build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index 7e50104..ad5cc2b 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -93,7 +93,7 @@
       return StalenessCheckResult.stale("Document %s missing from index", id);
     }
     ChangeData cd = result.get();
-    return check(repoManager, id, parseStates(cd), parsePatterns(cd));
+    return check(repoManager, id, cd.getRefStates(), parsePatterns(cd));
   }
 
   /**
@@ -127,10 +127,6 @@
     return StalenessCheckResult.notStale();
   }
 
-  private SetMultimap<Project.NameKey, RefState> parseStates(ChangeData cd) {
-    return RefState.parseStates(cd.getRefStates());
-  }
-
   private ListMultimap<Project.NameKey, RefStatePattern> parsePatterns(ChangeData cd) {
     return parsePatterns(cd.getRefStatePatterns());
   }
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index fcf0d6d..075a4ce 100644
--- a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -24,9 +24,9 @@
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.SiteIndexer;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsNoteDbConsistencyChecker;
 import com.google.gerrit.server.index.IndexExecutor;
@@ -36,7 +36,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Optional;
+import java.util.Map;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -92,6 +92,8 @@
     AtomicInteger done = new AtomicInteger();
     AtomicInteger failed = new AtomicInteger();
     Stopwatch sw = Stopwatch.createStarted();
+    groupCache.evict(uuids);
+    Map<AccountGroup.UUID, InternalGroup> reindexedGroups = groupCache.get(uuids);
     for (AccountGroup.UUID uuid : uuids) {
       String desc = "group " + uuid;
       ListenableFuture<?> future =
@@ -99,12 +101,12 @@
               () -> {
                 try {
                   groupCache.evict(uuid);
-                  Optional<InternalGroup> internalGroup = groupCache.get(uuid);
-                  if (internalGroup.isPresent()) {
+                  InternalGroup internalGroup = reindexedGroups.get(uuid);
+                  if (internalGroup != null) {
                     if (isFirstInsertForEntry.equals(isFirstInsertForEntry.YES)) {
-                      index.insert(internalGroup.get());
+                      index.insert(internalGroup);
                     } else {
-                      index.replace(internalGroup.get());
+                      index.replace(internalGroup);
                     }
                   } else {
                     index.delete(uuid);
diff --git a/java/com/google/gerrit/server/index/group/GroupField.java b/java/com/google/gerrit/server/index/group/GroupField.java
index a3d913d..df90c0d 100644
--- a/java/com/google/gerrit/server/index/group/GroupField.java
+++ b/java/com/google/gerrit/server/index/group/GroupField.java
@@ -25,10 +25,10 @@
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.SchemaUtil;
-import com.google.gerrit.server.group.InternalGroup;
 import java.sql.Timestamp;
 import org.eclipse.jgit.lib.ObjectId;
 
diff --git a/java/com/google/gerrit/server/index/group/GroupIndex.java b/java/com/google/gerrit/server/index/group/GroupIndex.java
index be569df..28c0384 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndex.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndex.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.index.group;
 
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.query.group.GroupPredicates;
 
 /**
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexCollection.java b/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
index 6c36a97..e1941ab 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
@@ -16,8 +16,8 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.IndexCollection;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Singleton;
 
 /** Collection of active group indices. See {@link IndexCollection} for details on collections. */
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java b/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
index a9cd856..7b7d2e2 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.IndexDefinition;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 
 /** Bundle of service classes that make up the group index. */
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java b/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
index a7b9497..86050bf 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
@@ -16,11 +16,11 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.IndexRewriter;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
index e9897e8..51e679f 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
@@ -18,11 +18,11 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index 40e0d8e..c4d8952 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -16,9 +16,9 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
-import com.google.gerrit.server.group.InternalGroup;
 
 /** Definition of group index versions (schemata). See {@link SchemaDefinitions}. */
 public class GroupSchemaDefinitions extends SchemaDefinitions<InternalGroup> {
diff --git a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
index 319b834..90070b6 100644
--- a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
+++ b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.index.group;
 
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
@@ -22,7 +23,6 @@
 import com.google.gerrit.index.query.IndexedQuery;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.group.InternalGroup;
 import java.util.HashSet;
 import java.util.Set;
 
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
index ac4df7b..35594e9 100644
--- a/java/com/google/gerrit/server/logging/LoggingContext.java
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -42,11 +42,12 @@
   private static final ThreadLocal<MutableTags> tags = new ThreadLocal<>();
   private static final ThreadLocal<Boolean> forceLogging = new ThreadLocal<>();
   private static final ThreadLocal<Boolean> performanceLogging = new ThreadLocal<>();
+  private static final ThreadLocal<Boolean> aclLogging = new ThreadLocal<>();
 
   /**
-   * When copying the logging context to a new thread we need to ensure that the performance log
-   * records that are added in the new thread are added to the same {@link
-   * MutablePerformanceLogRecords} instance (see {@link LoggingContextAwareRunnable} and {@link
+   * When copying the logging context to a new thread we need to ensure that the mutable log records
+   * (performance logs and ACL logs) that are added in the new thread are added to the same multable
+   * log records instance (see {@link LoggingContextAwareRunnable} and {@link
    * LoggingContextAwareCallable}). This is important since performance log records are processed
    * only at the end of the request and performance log records that are created in another thread
    * should not get lost.
@@ -54,6 +55,8 @@
   private static final ThreadLocal<MutablePerformanceLogRecords> performanceLogRecords =
       new ThreadLocal<>();
 
+  private static final ThreadLocal<MutableAclLogRecords> aclLogRecords = new ThreadLocal<>();
+
   private LoggingContext() {}
 
   /** This method is expected to be called via reflection (and might otherwise be unused). */
@@ -67,7 +70,9 @@
     }
 
     return new LoggingContextAwareRunnable(
-        runnable, getInstance().getMutablePerformanceLogRecords());
+        runnable,
+        getInstance().getMutablePerformanceLogRecords(),
+        getInstance().getMutableAclRecords());
   }
 
   public static <T> Callable<T> copy(Callable<T> callable) {
@@ -76,11 +81,18 @@
     }
 
     return new LoggingContextAwareCallable<>(
-        callable, getInstance().getMutablePerformanceLogRecords());
+        callable,
+        getInstance().getMutablePerformanceLogRecords(),
+        getInstance().getMutableAclRecords());
   }
 
   public boolean isEmpty() {
-    return tags.get() == null && forceLogging.get() == null && performanceLogging.get() == null;
+    return tags.get() == null
+        && forceLogging.get() == null
+        && performanceLogging.get() == null
+        && (performanceLogRecords.get() == null || performanceLogRecords.get().isEmtpy())
+        && aclLogging.get() == null
+        && (aclLogRecords.get() == null || aclLogRecords.get().isEmpty());
   }
 
   public void clear() {
@@ -88,6 +100,8 @@
     forceLogging.remove();
     performanceLogging.remove();
     performanceLogRecords.remove();
+    aclLogging.remove();
+    aclLogRecords.remove();
   }
 
   @Override
@@ -167,16 +181,13 @@
    * requests).
    *
    * @param enable whether performance logging should be enabled.
-   * @return whether performance logging was be enabled before invoking this method (old value).
    */
-  boolean performanceLogging(boolean enable) {
-    Boolean oldValue = performanceLogging.get();
+  void performanceLogging(boolean enable) {
     if (enable) {
       performanceLogging.set(true);
     } else {
       performanceLogging.remove();
     }
-    return oldValue != null ? oldValue : false;
   }
 
   /**
@@ -247,6 +258,97 @@
     return records;
   }
 
+  public boolean isAclLogging() {
+    Boolean isAclLogging = aclLogging.get();
+    return isAclLogging != null ? isAclLogging : false;
+  }
+
+  /**
+   * Enables ACL logging.
+   *
+   * <p>It's important to enable ACL logging only in a context that ensures to consume the captured
+   * ACL log records. Otherwise captured ACL log records might leak into other requests that are
+   * executed by the same thread (if a thread pool is used to process requests).
+   *
+   * @param enable whether ACL logging should be enabled.
+   * @return whether ACL logging was be enabled before invoking this method (old value).
+   */
+  boolean aclLogging(boolean enable) {
+    Boolean oldValue = aclLogging.get();
+    if (enable) {
+      aclLogging.set(true);
+    } else {
+      aclLogging.remove();
+    }
+    return oldValue != null ? oldValue : false;
+  }
+
+  /**
+   * Adds an ACL log record.
+   *
+   * @param aclLogRecord ACL log record
+   */
+  public void addAclLogRecord(String aclLogRecord) {
+    if (!isAclLogging()) {
+      return;
+    }
+
+    getMutableAclRecords().add(aclLogRecord);
+  }
+
+  ImmutableList<String> getAclLogRecords() {
+    MutableAclLogRecords records = aclLogRecords.get();
+    if (records != null) {
+      return records.list();
+    }
+    return ImmutableList.of();
+  }
+
+  /**
+   * Set the ACL log records in this logging context. Existing log records are overwritten.
+   *
+   * <p>This method makes a defensive copy of the passed in list.
+   *
+   * @param newAclLogRecords ACL log records that should be set
+   */
+  void setAclLogRecords(List<String> newAclLogRecords) {
+    if (newAclLogRecords.isEmpty()) {
+      aclLogRecords.remove();
+      return;
+    }
+
+    getMutableAclRecords().set(newAclLogRecords);
+  }
+
+  /**
+   * Sets a {@link MutableAclLogRecords} instance for storing ACL log records.
+   *
+   * <p><strong>Attention:</strong> The passed in {@link MutableAclLogRecords} instance is directly
+   * stored in the logging context.
+   *
+   * <p>This method is intended to be only used when the logging context is copied to a new thread
+   * to ensure that the ACL log records that are added in the new thread are added to the same
+   * {@link MutableAclLogRecords} instance (see {@link LoggingContextAwareRunnable} and {@link
+   * LoggingContextAwareCallable}). This is important since ACL log records are processed only at
+   * the end of the request and ACL log records that are created in another thread should not get
+   * lost.
+   *
+   * @param mutableAclLogRecords the {@link MutableAclLogRecords} instance in which ACL log records
+   *     should be stored
+   */
+  void setMutableAclLogRecords(MutableAclLogRecords mutableAclLogRecords) {
+    aclLogRecords.set(requireNonNull(mutableAclLogRecords));
+  }
+
+  private MutableAclLogRecords getMutableAclRecords() {
+    MutableAclLogRecords records = aclLogRecords.get();
+    if (records == null) {
+      records = new MutableAclLogRecords();
+      aclLogRecords.set(records);
+    }
+    return records;
+  }
+
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
@@ -254,6 +356,8 @@
         .add("forceLogging", forceLogging.get())
         .add("performanceLogging", performanceLogging.get())
         .add("performanceLogRecords", performanceLogRecords.get())
+        .add("aclLogging", aclLogging.get())
+        .add("aclLogRecords", aclLogRecords.get())
         .toString();
   }
 }
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
index 1adee1b..ab5db02 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
@@ -40,6 +40,8 @@
   private final boolean forceLogging;
   private final boolean performanceLogging;
   private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
+  private final boolean aclLogging;
+  private final MutableAclLogRecords mutableAclLogRecords;
 
   /**
    * Creates a LoggingContextAwareCallable that wraps the given {@link Callable}.
@@ -47,15 +49,21 @@
    * @param callable Callable that should be wrapped.
    * @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
    *     performance log records that are created from the runnable are added
+   * @param mutableAclLogRecords instance of {@link MutableAclLogRecords} to which ACL log records
+   *     that are created from the runnable are added
    */
   LoggingContextAwareCallable(
-      Callable<T> callable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+      Callable<T> callable,
+      MutablePerformanceLogRecords mutablePerformanceLogRecords,
+      MutableAclLogRecords mutableAclLogRecords) {
     this.callable = callable;
     this.callingThread = Thread.currentThread();
     this.tags = LoggingContext.getInstance().getTagsAsMap();
     this.forceLogging = LoggingContext.getInstance().isLoggingForced();
     this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
     this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
+    this.aclLogging = LoggingContext.getInstance().isAclLogging();
+    this.mutableAclLogRecords = mutableAclLogRecords;
   }
 
   @Override
@@ -76,6 +84,8 @@
     loggingCtx.forceLogging(forceLogging);
     loggingCtx.performanceLogging(performanceLogging);
     loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
+    loggingCtx.aclLogging(aclLogging);
+    loggingCtx.setMutableAclLogRecords(mutableAclLogRecords);
     try {
       return callable.call();
     } finally {
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
index d0559cc..3c4c563 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
@@ -58,6 +58,8 @@
   private final boolean forceLogging;
   private final boolean performanceLogging;
   private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
+  private final boolean aclLogging;
+  private final MutableAclLogRecords mutableAclLogRecords;
 
   /**
    * Creates a LoggingContextAwareRunnable that wraps the given {@link Runnable}.
@@ -65,15 +67,21 @@
    * @param runnable Runnable that should be wrapped.
    * @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
    *     performance log records that are created from the runnable are added
+   * @param mutableAclLogRecords instance of {@link MutableAclLogRecords} to which ACL log records
+   *     that are created from the runnable are added
    */
   LoggingContextAwareRunnable(
-      Runnable runnable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+      Runnable runnable,
+      MutablePerformanceLogRecords mutablePerformanceLogRecords,
+      MutableAclLogRecords mutableAclLogRecords) {
     this.runnable = runnable;
     this.callingThread = Thread.currentThread();
     this.tags = LoggingContext.getInstance().getTagsAsMap();
     this.forceLogging = LoggingContext.getInstance().isLoggingForced();
     this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
     this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
+    this.aclLogging = LoggingContext.getInstance().isAclLogging();
+    this.mutableAclLogRecords = mutableAclLogRecords;
   }
 
   public Runnable unwrap() {
@@ -99,6 +107,8 @@
     loggingCtx.forceLogging(forceLogging);
     loggingCtx.performanceLogging(performanceLogging);
     loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
+    loggingCtx.aclLogging(aclLogging);
+    loggingCtx.setMutableAclLogRecords(mutableAclLogRecords);
     try {
       runnable.run();
     } finally {
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 1a4a335..71ee01f 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -103,6 +103,9 @@
   public abstract Optional<Integer> indexVersion();
 
   /** The name of the implementation method. */
+  public abstract Optional<String> memoryPoolName();
+
+  /** The name of the implementation method. */
   public abstract Optional<String> methodName();
 
   /** One or more resources */
@@ -309,6 +312,8 @@
 
     public abstract Builder indexVersion(int indexVersion);
 
+    public abstract Builder memoryPoolName(@Nullable String memoryPoolName);
+
     public abstract Builder methodName(@Nullable String methodName);
 
     public abstract Builder multiple(boolean multiple);
diff --git a/java/com/google/gerrit/server/logging/MutableAclLogRecords.java b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
new file mode 100644
index 0000000..a692d2b
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Thread-safe store for ACL log records.
+ *
+ * <p>This class is intended to keep track of user ACL records in {@link LoggingContext}. It needs
+ * to be thread-safe because it gets shared between threads when the logging context is copied to
+ * another thread (see {@link LoggingContextAwareRunnable} and {@link LoggingContextAwareCallable}.
+ * In this case the logging contexts of both threads share the same instance of this class. This is
+ * important since ACL log records are processed only at the end of a request and user ACL records
+ * that are created in another thread should not get lost.
+ */
+public class MutableAclLogRecords {
+  private final ArrayList<String> aclLogRecords = new ArrayList<>();
+
+  public synchronized void add(String record) {
+    aclLogRecords.add(record);
+  }
+
+  public synchronized void set(List<String> records) {
+    aclLogRecords.clear();
+    aclLogRecords.addAll(records);
+  }
+
+  public synchronized ImmutableList<String> list() {
+    return ImmutableList.copyOf(aclLogRecords);
+  }
+
+  public boolean isEmpty() {
+    return aclLogRecords.isEmpty();
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this).add("aclLogRecords", aclLogRecords).toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java b/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
index 4ee70d7..2965719 100644
--- a/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
+++ b/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
@@ -46,6 +46,10 @@
     return ImmutableList.copyOf(performanceLogRecords);
   }
 
+  public boolean isEmtpy() {
+    return performanceLogRecords.isEmpty();
+  }
+
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index 21a4ce6..2fc19b5 100644
--- a/java/com/google/gerrit/server/logging/TraceContext.java
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Stopwatch;
 import com.google.common.base.Strings;
 import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
@@ -222,9 +223,17 @@
   // Table<TAG_NAME, TAG_VALUE, REMOVE_ON_CLOSE>
   private final Table<String, String, Boolean> tags = HashBasedTable.create();
 
-  private boolean stopForceLoggingOnClose;
+  private final boolean oldAclLogging;
+  private final ImmutableList<String> oldAclLogRecords;
 
-  private TraceContext() {}
+  private boolean stopForceLoggingOnClose;
+  private boolean stopAclLoggingOnClose;
+
+  private TraceContext() {
+    // Just in case remember the old state and reset ACL log entries.
+    this.oldAclLogging = LoggingContext.getInstance().isAclLogging();
+    this.oldAclLogRecords = LoggingContext.getInstance().getAclLogRecords();
+  }
 
   public TraceContext addTag(RequestId.Type requestId, Object tagValue) {
     return addTag(requireNonNull(requestId, "request ID is required").name(), tagValue);
@@ -265,6 +274,23 @@
         .findFirst();
   }
 
+  public TraceContext enableAclLogging() {
+    if (stopAclLoggingOnClose) {
+      return this;
+    }
+
+    stopAclLoggingOnClose = !LoggingContext.getInstance().aclLogging(true);
+    return this;
+  }
+
+  public boolean isAclLoggingEnabled() {
+    return LoggingContext.getInstance().isAclLogging();
+  }
+
+  public ImmutableList<String> getAclLogRecords() {
+    return LoggingContext.getInstance().getAclLogRecords();
+  }
+
   @Override
   public void close() {
     for (Table.Cell<String, String, Boolean> cell : tags.cellSet()) {
@@ -275,5 +301,10 @@
     if (stopForceLoggingOnClose) {
       LoggingContext.getInstance().forceLogging(false);
     }
+
+    if (stopAclLoggingOnClose) {
+      LoggingContext.getInstance().aclLogging(oldAclLogging);
+      LoggingContext.getInstance().setAclLogRecords(oldAclLogRecords);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 7890d35..81ce101 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -91,12 +91,14 @@
   protected ProjectState projectState;
   protected Set<Account.Id> authors;
   protected boolean emailOnlyAuthors;
+  protected boolean emailOnlyAttentionSetIfEnabled;
 
   protected ChangeEmail(EmailArguments args, String messageClass, ChangeData changeData) {
     super(args, messageClass, changeData.change().getDest());
     this.changeData = changeData;
     this.change = changeData.change();
     this.emailOnlyAuthors = false;
+    this.emailOnlyAttentionSetIfEnabled = true;
     this.currentAttentionSet = getAttentionSet();
   }
 
@@ -409,7 +411,8 @@
   private void addRecipient(RecipientType rt, Account.Id to, boolean isWatcher) {
     if (!isWatcher) {
       Optional<AccountState> accountState = args.accountCache.get(to);
-      if (accountState.isPresent()
+      if (emailOnlyAttentionSetIfEnabled
+          && accountState.isPresent()
           && accountState.get().generalPreferences().getEmailStrategy()
               == EmailStrategy.ATTENTION_SET_ONLY
           && !currentAttentionSet.contains(to)) {
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index d5863a6..0de0dbe 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -61,7 +61,7 @@
     bccStarredBy();
     ccExistingReviewers();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    add(RecipientType.TO, reviewers);
+    reviewers.stream().forEach(r -> add(RecipientType.TO, r));
     addByEmail(RecipientType.TO, reviewersByEmail);
     removeUsersThatIgnoredTheChange();
   }
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
index 1b58057..aade30f 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
@@ -113,6 +113,11 @@
 
   private void addTemplate(SoyFileSet.Builder builder, String resourcePath, String name)
       throws ProvisionException {
+    if (!resourcePath.endsWith("/")) {
+      resourcePath += "/";
+    }
+    String logicalPath = resourcePath + name;
+
     // Load as a file in the mail templates directory if present.
     Path tmpl = site.mail_dir.resolve(name);
     if (Files.isRegularFile(tmpl)) {
@@ -125,14 +130,11 @@
         throw new ProvisionException(
             "Failed to read template file " + tmpl.toAbsolutePath().toString(), err);
       }
-      builder.add(content, tmpl.toAbsolutePath().toString());
+      builder.add(content, logicalPath);
       return;
     }
 
     // Otherwise load the template as a resource.
-    if (!resourcePath.endsWith("/")) {
-      resourcePath += "/";
-    }
-    builder.add(Resources.getResource(resourcePath + name));
+    builder.add(Resources.getResource(logicalPath), logicalPath);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index 928bdc3..ea76ab8 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -28,24 +28,34 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.util.Optional;
 
 /** Send notice about a change successfully merged. */
 public class MergedSender extends ReplyToChangeSender {
   public interface Factory {
-    MergedSender create(Project.NameKey project, Change.Id changeId);
+    MergedSender create(
+        Project.NameKey project, Change.Id changeId, Optional<String> stickyApprovalDiff);
   }
 
   private final LabelTypes labelTypes;
+  private final Optional<String> stickyApprovalDiff;
 
   @Inject
   public MergedSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+      EmailArguments args,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id changeId,
+      @Assisted Optional<String> stickyApprovalDiff) {
     super(args, "merged", newChangeData(args, project, changeId));
     labelTypes = changeData.getLabelTypes();
+    this.stickyApprovalDiff = stickyApprovalDiff;
   }
 
   @Override
   protected void init() throws EmailException {
+    // We want to send the submit email even if the "send only when in attention set" is enabled.
+    emailOnlyAttentionSetIfEnabled = false;
+
     super.init();
 
     ccAllApprovals();
@@ -130,5 +140,8 @@
   protected void setupSoyContext() {
     super.setupSoyContext();
     soyContextEmailData.put("approvals", getApprovals());
+    if (stickyApprovalDiff.isPresent()) {
+      soyContextEmailData.put("stickyApprovalDiff", stickyApprovalDiff.get());
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 0e97f7e..ee9a328 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -65,11 +65,11 @@
         break;
       case ALL:
       default:
-        add(RecipientType.CC, extraCC);
+        extraCC.stream().forEach(cc -> add(RecipientType.CC, cc));
         extraCCByEmail.stream().forEach(cc -> add(RecipientType.CC, cc));
         // $FALL-THROUGH$
       case OWNER_REVIEWERS:
-        add(RecipientType.TO, reviewers, true);
+        reviewers.stream().forEach(r -> add(RecipientType.TO, r, true));
         addByEmail(RecipientType.TO, reviewersByEmail, true);
         break;
     }
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index a23c978..746a07a 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.entities.EmailHeader.AddressList;
-import com.google.gerrit.entities.UserIdentity;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -57,7 +56,7 @@
 
 /** Sends an email to one or more interested parties. */
 public abstract class OutgoingEmail {
-  private static final String SOY_TEMPLATE_NAMESPACE = "com.google.gerrit.server.mail.template.";
+  private static final String SOY_TEMPLATE_NAMESPACE = "com.google.gerrit.server.mail.template";
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   protected String messageClass;
@@ -285,7 +284,7 @@
     setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
 
     for (RecipientType recipientType : notify.accounts().keySet()) {
-      add(recipientType, notify.accounts().get(recipientType));
+      notify.accounts().get(recipientType).stream().forEach(a -> add(recipientType, a));
     }
 
     setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass);
@@ -476,40 +475,18 @@
     return true;
   }
 
-  /** Schedule this message for delivery to the listed accounts. */
-  protected void add(RecipientType rt, Collection<Account.Id> list) {
-    add(rt, list, false);
-  }
-
-  /** Schedule this message for delivery to the listed accounts. */
-  protected void add(RecipientType rt, Collection<Account.Id> list, boolean override) {
-    for (final Account.Id id : list) {
-      add(rt, id, override);
-    }
-  }
-
   /** Schedule this message for delivery to the listed address. */
-  protected void addByEmail(RecipientType rt, Collection<Address> list) {
+  protected final void addByEmail(RecipientType rt, Collection<Address> list) {
     addByEmail(rt, list, false);
   }
 
   /** Schedule this message for delivery to the listed address. */
-  protected void addByEmail(RecipientType rt, Collection<Address> list, boolean override) {
+  protected final void addByEmail(RecipientType rt, Collection<Address> list, boolean override) {
     for (final Address id : list) {
       add(rt, id, override);
     }
   }
 
-  protected void add(RecipientType rt, UserIdentity who) {
-    add(rt, who, false);
-  }
-
-  protected void add(RecipientType rt, UserIdentity who, boolean override) {
-    if (who != null && who.getAccount() != null) {
-      add(rt, who.getAccount(), override);
-    }
-  }
-
   /** Schedule delivery of this message to the given account. */
   protected void add(RecipientType rt, Account.Id to) {
     add(rt, to, false);
@@ -536,11 +513,11 @@
   }
 
   /** Schedule delivery of this message to the given account. */
-  protected void add(RecipientType rt, Address addr) {
+  protected final void add(RecipientType rt, Address addr) {
     add(rt, addr, false);
   }
 
-  protected void add(RecipientType rt, Address addr, boolean override) {
+  protected final void add(RecipientType rt, Address addr, boolean override) {
     if (addr != null && addr.email() != null && addr.email().length() > 0) {
       if (!args.validator.isValid(addr.email())) {
         logger.atWarning().log("Not emailing %s (invalid email address)", addr.email());
@@ -611,10 +588,22 @@
 
   /** Configures a soy renderer for the given template name and rendering data map. */
   private SoySauce.Renderer configureRenderer(String templateName) {
-    return args.soySauce
-        .get()
-        .renderTemplate(SOY_TEMPLATE_NAMESPACE + templateName)
-        .setData(soyContext);
+    int baseNameIndex = templateName.indexOf("_");
+    // In case there are multiple templates in file (now only InboundEmailRejection and
+    // InboundEmailRejectionHtml).
+    String fileNamespace =
+        baseNameIndex == -1 ? templateName : templateName.substring(0, baseNameIndex);
+    String templateInFileNamespace =
+        String.join(".", SOY_TEMPLATE_NAMESPACE, fileNamespace, templateName);
+    String templateInCommonNamespace = String.join(".", SOY_TEMPLATE_NAMESPACE, templateName);
+    SoySauce soySauce = args.soySauce.get();
+    // For backwards compatibility with existing customizations and plugin templates with the
+    // old non-unique namespace.
+    String fullTemplateName =
+        soySauce.hasTemplate(templateInFileNamespace)
+            ? templateInFileNamespace
+            : templateInCommonNamespace;
+    return soySauce.renderTemplate(fullTemplateName).setData(soyContext);
   }
 
   protected void removeUser(Account user) {
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 0514337..173b121 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -33,7 +33,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gerrit.server.query.change.GroupBackedUser;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
@@ -150,7 +150,7 @@
       throws QueryParseException {
     logger.atFine().log("Checking watchers for notify config %s from project %s", nc, projectName);
     for (GroupReference groupRef : nc.getGroups()) {
-      CurrentUser user = new SingleGroupUser(groupRef.getUUID());
+      CurrentUser user = new GroupBackedUser(ImmutableSet.of(groupRef.getUUID()));
       if (filterMatch(user, nc.getFilter())) {
         deliverToMembers(matching.list(nc.getHeader()), groupRef.getUUID());
         logger.atFine().log("Added watchers for group %s", groupRef);
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index 5caac37..9516b9f 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -64,8 +64,8 @@
     if (args.settings.sendNewPatchsetEmails) {
       if (notify.handling() == NotifyHandling.ALL
           || notify.handling() == NotifyHandling.OWNER_REVIEWERS) {
-        add(RecipientType.TO, reviewers);
-        add(RecipientType.CC, extraCC);
+        reviewers.stream().forEach(r -> add(RecipientType.TO, r));
+        extraCC.stream().forEach(cc -> add(RecipientType.CC, cc));
       }
       rcptToAuthors(RecipientType.CC);
     }
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index e81160a..74ecd68 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -124,6 +124,10 @@
     this.revision = metaSha1;
   }
 
+  protected AbstractChangeNotes(Args args, Change.Id changeId) {
+    this(args, changeId, null);
+  }
+
   public Change.Id getChangeId() {
     return changeId;
   }
@@ -175,7 +179,8 @@
    * <p>Implementations may override this method to provide auto-rebuilding behavior.
    *
    * @param repo open repository.
-   * @param id version SHA1 of the change notes to load
+   * @param id SHA1 of the entity to read from the repository. The SHA1 is not sanity checked and is
+   *     assumed to be valid. If null, lookup SHA1 from the /meta ref.
    * @return handle for reading the entity.
    * @throws NoSuchChangeException change does not exist.
    * @throws IOException a repo-level error occurred.
@@ -185,6 +190,7 @@
     if (id == null) {
       id = readRef(repo);
     }
+
     return new LoadHandle(repo, id);
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 28f25ec5..6500d92 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -64,6 +64,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -79,6 +80,9 @@
 import org.eclipse.jgit.lib.Repository;
 
 /** View of a single {@link Change} based on the log of its notes branch. */
+// TODO(paiking): This class should be refactored to get rid of potentially duplicate or unneeded
+// variables, such as allAttentionSetUpdates, reviewerUpdates, and others.
+
 public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -189,8 +193,9 @@
     }
 
     /**
-     * Create change notes based on a {@link Change.Id}. This requires using the Change index and
-     * should only be used when {@link Project.NameKey} and the numeric change ID are not available.
+     * Create change notes based on a {@link com.google.gerrit.entities.Change.Id}. This requires
+     * using the Change index and should only be used when {@link
+     * com.google.gerrit.entities.Project.NameKey} and the numeric change ID are not available.
      */
     public ChangeNotes createCheckedUsingIndexLookup(Change.Id changeId) {
       InternalChangeQuery query = queryProvider.get().noFields();
@@ -206,9 +211,9 @@
     }
 
     /**
-     * Create change notes based on a list of {@link Change.Id}s. This requires using the Change
-     * index and should only be used when {@link Project.NameKey} and the numeric change ID are not
-     * available.
+     * Create change notes based on a list of {@link com.google.gerrit.entities.Change.Id}s. This
+     * requires using the Change index and should only be used when {@link
+     * com.google.gerrit.entities.Project.NameKey} and the numeric change ID are not available.
      */
     public List<ChangeNotes> createUsingIndexLookup(Collection<Change.Id> changeIds) {
       List<ChangeNotes> notes = new ArrayList<>();
@@ -456,6 +461,11 @@
     return state.attentionSet();
   }
 
+  /** Returns all updates for the attention set. */
+  public ImmutableList<AttentionSetUpdate> getAttentionSetUpdates() {
+    return state.allAttentionSetUpdates();
+  }
+
   /**
    * @return an ImmutableSet of Account.Ids of all users that have been assigned to this change. The
    *     order of the set is the order in which they were assigned.
@@ -519,6 +529,11 @@
     return state.updateCount();
   }
 
+  /** @return {@link Optional} value of time when the change was merged. */
+  public Optional<Timestamp> getMergedOn() {
+    return Optional.ofNullable(state.mergedOn());
+  }
+
   public ImmutableListMultimap<ObjectId, HumanComment> getDraftComments(Account.Id author) {
     return getDraftComments(author, null);
   }
@@ -564,6 +579,7 @@
   }
 
   public RobotCommentNotes getRobotCommentNotes() {
+    loadRobotComments();
     return robotCommentNotes;
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 7fde297..c554ca5 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -121,12 +121,18 @@
     // Single Timestamp overhead.
     private static final int T = O + 8;
 
+    /**
+     * {@inheritDoc}
+     *
+     * <p>Take all columns and all collection sizes into account, but use estimated average element
+     * sizes rather than iterating over collections. Numbers are largely hand-wavy based on
+     * http://stackoverflow.com/questions/258120/what-is-the-memory-consumption-of-an-object-in-java
+     *
+     * <p>Should be kept up to date with {@link ChangeNotesState}. Please, keep weights listed in
+     * the same order as fields.
+     */
     @Override
     public int weigh(Key key, ChangeNotesState state) {
-      // Take all columns and all collection sizes into account, but use
-      // estimated average element sizes rather than iterating over collections.
-      // Numbers are largely hand-wavy based on
-      // http://stackoverflow.com/questions/258120/what-is-the-memory-consumption-of-an-object-in-java
       return P
           + O
           + 20 // metaId
@@ -138,6 +144,7 @@
           + K // owner
           + P
           + str(state.columns().branch())
+          + P // status
           + P
           + patchSetId() // currentPatchSetId
           + P
@@ -148,9 +155,16 @@
           + str(state.columns().originalSubject())
           + P
           + str(state.columns().submissionId())
-          + P // status
+          + 1 // isPrivate
+          + 1 // workInProgress
+          + 1 // reviewStarted
+          + P
+          + K // revertOf
+          + P
+          + patchSetId() // cherryPickOf
           + P
           + set(state.hashtags(), str(10))
+          + str(state.serverId()) // serverId
           + P
           + list(state.patchSets(), patchSet())
           + P
@@ -168,15 +182,17 @@
           + P
           + list(state.assigneeUpdates(), 4 * O + K + K)
           + P
+          + set(state.attentionSet(), 4 * O + K + I + str(15))
+          + P
+          + list(state.allAttentionSetUpdates(), 4 * O + K + I + str(15))
+          + P
           + list(state.submitRecords(), P + list(2, str(4) + P + K) + P)
           + P
           + list(state.changeMessages(), changeMessage())
           + P
           + map(state.publishedComments().asMap(), comment())
-          + 1 // isPrivate
-          + 1 // workInProgress
-          + 1 // reviewStarted
-          + I; // updateCount
+          + I // updateCount
+          + T; // mergedOn
     }
 
     private static int str(String s) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 6f9f7e8..2eb69e6f 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -119,6 +119,8 @@
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   /** Holds only the most recent update per user. Older updates are discarded. */
   private final Map<Account.Id, AttentionSetUpdate> latestAttentionStatus;
+  /** Holds all updates to attention set. */
+  private final List<AttentionSetUpdate> allAttentionSetUpdates;
 
   private final List<AssigneeStatusUpdate> assigneeUpdates;
   private final List<SubmitRecord> submitRecords;
@@ -158,6 +160,7 @@
   // We only set the value once, based on the latest update (the actual value or Optional.empty() if
   // the latest record unsets the field).
   private Optional<PatchSet.Id> cherryPickOf;
+  private Timestamp mergedOn;
 
   ChangeNotesParser(
       Change.Id changeId,
@@ -179,6 +182,7 @@
     allPastReviewers = new ArrayList<>();
     reviewerUpdates = new ArrayList<>();
     latestAttentionStatus = new HashMap<>();
+    allAttentionSetUpdates = new ArrayList<>();
     assigneeUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
@@ -250,6 +254,7 @@
         allPastReviewers,
         buildReviewerUpdates(),
         ImmutableSet.copyOf(latestAttentionStatus.values()),
+        allAttentionSetUpdates,
         assigneeUpdates,
         submitRecords,
         buildAllMessages(),
@@ -259,7 +264,8 @@
         firstNonNull(hasReviewStarted, true),
         revertOf,
         cherryPickOf != null ? cherryPickOf.orElse(null) : null,
-        updateCount);
+        updateCount,
+        mergedOn);
   }
 
   private Map<PatchSet.Id, PatchSet> buildPatchSets() throws ConfigInvalidException {
@@ -321,9 +327,9 @@
   }
 
   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
-    Timestamp ts = new Timestamp(commit.getCommitterIdent().getWhen().getTime());
+    Timestamp commitTimestamp = getCommitTimestamp(commit);
 
-    createdOn = ts;
+    createdOn = commitTimestamp;
     parseTag(commit);
 
     if (branch == null) {
@@ -363,21 +369,20 @@
       originalSubject = currSubject;
     }
 
-    boolean hasChangeMessage = parseChangeMessage(psId, accountId, realAccountId, commit, ts);
+    boolean hasChangeMessage =
+        parseChangeMessage(psId, accountId, realAccountId, commit, commitTimestamp);
     if (topic == null) {
       topic = parseTopic(commit);
     }
 
     parseHashtags(commit);
     parseAttentionSetUpdates(commit);
-    parseAssigneeUpdates(ts, commit);
+    parseAssigneeUpdates(commitTimestamp, commit);
 
-    if (submissionId == null) {
-      submissionId = parseSubmissionId(commit);
-    }
+    parseSubmission(commit, commitTimestamp);
 
-    if (lastUpdatedOn == null || ts.after(lastUpdatedOn)) {
-      lastUpdatedOn = ts;
+    if (lastUpdatedOn == null || commitTimestamp.after(lastUpdatedOn)) {
+      lastUpdatedOn = commitTimestamp;
     }
 
     if (deletedPatchSets.contains(psId)) {
@@ -391,16 +396,10 @@
 
     ObjectId currRev = parseRevision(commit);
     if (currRev != null) {
-      parsePatchSet(psId, currRev, accountId, ts);
+      parsePatchSet(psId, currRev, accountId, commitTimestamp);
     }
     parseCurrentPatchSet(psId, commit);
 
-    if (submitRecords.isEmpty()) {
-      // Only parse the most recent set of submit records; any older ones are
-      // still there, but not currently used.
-      parseSubmitRecords(commit.getFooterLineValues(FOOTER_SUBMITTED_WITH));
-    }
-
     if (status == null) {
       status = parseStatus(commit);
     }
@@ -408,15 +407,15 @@
     // Parse approvals after status to treat approvals in the same commit as
     // "Status: merged" as non-post-submit.
     for (String line : commit.getFooterLineValues(FOOTER_LABEL)) {
-      parseApproval(psId, accountId, realAccountId, ts, line);
+      parseApproval(psId, accountId, realAccountId, commitTimestamp, line);
     }
 
     for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
       for (String line : commit.getFooterLineValues(state.getFooterKey())) {
-        parseReviewer(ts, state, line);
+        parseReviewer(commitTimestamp, state, line);
       }
       for (String line : commit.getFooterLineValues(state.getByEmailFooterKey())) {
-        parseReviewerByEmail(ts, state, line);
+        parseReviewerByEmail(commitTimestamp, state, line);
       }
       // Don't update timestamp when a reviewer was added, matching RevewDb
       // behavior.
@@ -441,6 +440,24 @@
     }
   }
 
+  private void parseSubmission(ChangeNotesCommit commit, Timestamp commitTimestamp)
+      throws ConfigInvalidException {
+    // Only parse the most recent sumbit commit (there should be exactly one).
+    if (submissionId == null) {
+      submissionId = parseSubmissionId(commit);
+    }
+
+    if (submissionId != null && mergedOn == null) {
+      mergedOn = commitTimestamp;
+    }
+
+    if (submitRecords.isEmpty()) {
+      // Only parse the most recent set of submit records; any older ones are
+      // still there, but not currently used.
+      parseSubmitRecords(commit.getFooterLineValues(FOOTER_SUBMITTED_WITH));
+    }
+  }
+
   private String parseSubmissionId(ChangeNotesCommit commit) throws ConfigInvalidException {
     return parseOneFooter(commit, FOOTER_SUBMISSION_ID);
   }
@@ -595,6 +612,9 @@
       }
       // Processing is in reverse chronological order. Keep only the latest update.
       latestAttentionStatus.putIfAbsent(attentionStatus.get().account(), attentionStatus.get());
+
+      // Keep all updates as well.
+      allAttentionSetUpdates.add(attentionStatus.get());
     }
   }
 
@@ -884,6 +904,10 @@
         }
       } else {
         checkFooter(rec != null, FOOTER_SUBMITTED_WITH, line);
+        if (line.startsWith("Rule-Name")) {
+          // This is just added for forward compatibility. Ignore this field.
+          continue;
+        }
         SubmitRecord.Label label = new SubmitRecord.Label();
         if (rec.labels == null) {
           rec.labels = new ArrayList<>();
@@ -914,7 +938,7 @@
     if (a.getName().equals(c.getName()) && a.getEmailAddress().equals(c.getEmailAddress())) {
       return null;
     }
-    return parseIdent(commit.getAuthorIdent());
+    return parseIdent(a);
   }
 
   private void parseReviewer(Timestamp ts, ReviewerStateInternal state, String line)
@@ -1010,7 +1034,7 @@
    * @return {@link Optional} value of the parsed footer or {@code null} if the footer is missing in
    *     this commit.
    * @throws ConfigInvalidException if the footer value could not be parsed as a valid {@link
-   *     PatchSet.Id}.
+   *     com.google.gerrit.entities.PatchSet.Id}.
    */
   @Nullable
   private Optional<PatchSet.Id> parseCherryPickOf(ChangeNotesCommit commit)
@@ -1031,6 +1055,23 @@
     }
   }
 
+  /**
+   * Returns the {@link Timestamp} when the commit was applied.
+   *
+   * <p>The author's date only notes when the commit was originally made. Thus, use the commiter's
+   * date as it accounts for the rebase, cherry-pick, commit --amend and other commands that rewrite
+   * the history of the branch.
+   *
+   * <p>Don't use {@link org.eclipse.jgit.revwalk.RevCommit#getCommitTime} directly because it
+   * returns int and would overflow.
+   *
+   * @param commit the commit to return commit time.
+   * @return the timestamp when the commit was applied.
+   */
+  private Timestamp getCommitTimestamp(ChangeNotesCommit commit) {
+    return new Timestamp(commit.getCommitterIdent().getWhen().getTime());
+  }
+
   private void pruneReviewers() {
     Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Timestamp>> rit =
         reviewers.cellSet().iterator();
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 76c4678..33bc039 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
-import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.AssigneeStatusUpdate;
@@ -65,8 +64,6 @@
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord;
 import com.google.gson.Gson;
-import com.google.protobuf.ByteString;
-import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.List;
@@ -81,9 +78,15 @@
  * <p>One instance is the output of a single {@link ChangeNotesParser}, and contains types required
  * to support public methods on {@link ChangeNotes}. It is intended to be cached in-process.
  *
+ * <p>When new fields are added to the {@link ChangeNotesState}, {@link
+ * ChangeNotesCache.Weigher#weigh} should be updated.
+ *
  * <p>Note that {@link ChangeNotes} contains more than just a single {@code ChangeNoteState}, such
  * as per-draft information, so that class is not cached directly.
  */
+// TODO(paiking): This class should be refactored to get rid of potentially duplicate or unneeded
+// variables, such as allAttentionSetUpdates, reviewerUpdates, and others.
+
 @AutoValue
 public abstract class ChangeNotesState {
 
@@ -120,6 +123,7 @@
       List<Account.Id> allPastReviewers,
       List<ReviewerStatusUpdate> reviewerUpdates,
       Set<AttentionSetUpdate> attentionSetUpdates,
+      List<AttentionSetUpdate> allAttentionSetUpdates,
       List<AssigneeStatusUpdate> assigneeUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
@@ -129,7 +133,8 @@
       boolean reviewStarted,
       @Nullable Change.Id revertOf,
       @Nullable PatchSet.Id cherryPickOf,
-      int updateCount) {
+      int updateCount,
+      @Nullable Timestamp mergedOn) {
     requireNonNull(
         metaId,
         () ->
@@ -171,11 +176,13 @@
         .allPastReviewers(allPastReviewers)
         .reviewerUpdates(reviewerUpdates)
         .attentionSet(attentionSetUpdates)
+        .allAttentionSetUpdates(allAttentionSetUpdates)
         .assigneeUpdates(assigneeUpdates)
         .submitRecords(submitRecords)
         .changeMessages(changeMessages)
         .publishedComments(publishedComments)
         .updateCount(updateCount)
+        .mergedOn(mergedOn)
         .build();
   }
 
@@ -305,9 +312,12 @@
 
   abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
 
-  /** Returns the most recent update (i.e. current status status) per user. */
+  /** Returns the most recent update (i.e. current status) per user. */
   abstract ImmutableSet<AttentionSetUpdate> attentionSet();
 
+  /** Returns all attention set updates. */
+  abstract ImmutableList<AttentionSetUpdate> allAttentionSetUpdates();
+
   abstract ImmutableList<AssigneeStatusUpdate> assigneeUpdates();
 
   abstract ImmutableList<SubmitRecord> submitRecords();
@@ -318,6 +328,9 @@
 
   abstract int updateCount();
 
+  @Nullable
+  abstract Timestamp mergedOn();
+
   Change newChange(Project.NameKey project) {
     ChangeColumns c = requireNonNull(columns(), "columns are required");
     Change change =
@@ -386,6 +399,7 @@
           .allPastReviewers(ImmutableList.of())
           .reviewerUpdates(ImmutableList.of())
           .attentionSet(ImmutableSet.of())
+          .allAttentionSetUpdates(ImmutableList.of())
           .assigneeUpdates(ImmutableList.of())
           .submitRecords(ImmutableList.of())
           .changeMessages(ImmutableList.of())
@@ -421,6 +435,8 @@
 
     abstract Builder attentionSet(Set<AttentionSetUpdate> attentionSetUpdates);
 
+    abstract Builder allAttentionSetUpdates(List<AttentionSetUpdate> attentionSetUpdates);
+
     abstract Builder assigneeUpdates(List<AssigneeStatusUpdate> assigneeUpdates);
 
     abstract Builder submitRecords(List<SubmitRecord> submitRecords);
@@ -431,9 +447,14 @@
 
     abstract Builder updateCount(int updateCount);
 
+    abstract Builder mergedOn(Timestamp mergedOn);
+
     abstract ChangeNotesState build();
   }
 
+  /**
+   * Convert ChangeNotesState (which is AutoValue based) to byte[] and back, using protocol buffers.
+   */
   enum Serializer implements CacheSerializer<ChangeNotesState> {
     INSTANCE;
 
@@ -461,13 +482,11 @@
       object.hashtags().forEach(b::addHashtag);
       object
           .patchSets()
-          .forEach(e -> b.addPatchSet(toByteString(e.getValue(), PatchSetProtoConverter.INSTANCE)));
+          .forEach(e -> b.addPatchSet(PatchSetProtoConverter.INSTANCE.toProto(e.getValue())));
       object
           .approvals()
           .forEach(
-              e ->
-                  b.addApproval(
-                      toByteString(e.getValue(), PatchSetApprovalProtoConverter.INSTANCE)));
+              e -> b.addApproval(PatchSetApprovalProtoConverter.INSTANCE.toProto(e.getValue())));
 
       object.reviewers().asTable().cellSet().forEach(c -> b.addReviewer(toReviewerSetEntry(c)));
       object
@@ -489,25 +508,26 @@
       object.allPastReviewers().forEach(a -> b.addPastReviewer(a.get()));
       object.reviewerUpdates().forEach(u -> b.addReviewerUpdate(toReviewerStatusUpdateProto(u)));
       object.attentionSet().forEach(u -> b.addAttentionSetUpdate(toAttentionSetUpdateProto(u)));
+      object
+          .allAttentionSetUpdates()
+          .forEach(u -> b.addAllAttentionSetUpdate(toAttentionSetUpdateProto(u)));
       object.assigneeUpdates().forEach(u -> b.addAssigneeUpdate(toAssigneeStatusUpdateProto(u)));
       object
           .submitRecords()
           .forEach(r -> b.addSubmitRecord(GSON.toJson(new StoredSubmitRecord(r))));
       object
           .changeMessages()
-          .forEach(m -> b.addChangeMessage(toByteString(m, ChangeMessageProtoConverter.INSTANCE)));
+          .forEach(m -> b.addChangeMessage(ChangeMessageProtoConverter.INSTANCE.toProto(m)));
       object.publishedComments().values().forEach(c -> b.addPublishedComment(GSON.toJson(c)));
       b.setUpdateCount(object.updateCount());
+      if (object.mergedOn() != null) {
+        b.setMergedOnMillis(object.mergedOn().getTime());
+        b.setHasMergedOn(true);
+      }
 
       return Protos.toByteArray(b.build());
     }
 
-    @VisibleForTesting
-    static <T> ByteString toByteString(T object, ProtoConverter<?, T> converter) {
-      MessageLite message = converter.toProto(object);
-      return Protos.toByteString(message);
-    }
-
     private static ChangeColumnsProto toChangeColumnsProto(ChangeColumns cols) {
       ChangeColumnsProto.Builder b =
           ChangeColumnsProto.newBuilder()
@@ -607,12 +627,12 @@
               .hashtags(proto.getHashtagList())
               .patchSets(
                   proto.getPatchSetList().stream()
-                      .map(bytes -> parseProtoFrom(PatchSetProtoConverter.INSTANCE, bytes))
+                      .map(msg -> PatchSetProtoConverter.INSTANCE.fromProto(msg))
                       .map(ps -> Maps.immutableEntry(ps.id(), ps))
                       .collect(toImmutableList()))
               .approvals(
                   proto.getApprovalList().stream()
-                      .map(bytes -> parseProtoFrom(PatchSetApprovalProtoConverter.INSTANCE, bytes))
+                      .map(msg -> PatchSetApprovalProtoConverter.INSTANCE.fromProto(msg))
                       .map(a -> Maps.immutableEntry(a.patchSetId(), a))
                       .collect(toImmutableList()))
               .reviewers(toReviewerSet(proto.getReviewerList()))
@@ -623,6 +643,8 @@
                   proto.getPastReviewerList().stream().map(Account::id).collect(toImmutableList()))
               .reviewerUpdates(toReviewerStatusUpdateList(proto.getReviewerUpdateList()))
               .attentionSet(toAttentionSetUpdates(proto.getAttentionSetUpdateList()))
+              .allAttentionSetUpdates(
+                  toAllAttentionSetUpdates(proto.getAllAttentionSetUpdateList()))
               .assigneeUpdates(toAssigneeStatusUpdateList(proto.getAssigneeUpdateList()))
               .submitRecords(
                   proto.getSubmitRecordList().stream()
@@ -630,22 +652,17 @@
                       .collect(toImmutableList()))
               .changeMessages(
                   proto.getChangeMessageList().stream()
-                      .map(bytes -> parseProtoFrom(ChangeMessageProtoConverter.INSTANCE, bytes))
+                      .map(msg -> ChangeMessageProtoConverter.INSTANCE.fromProto(msg))
                       .collect(toImmutableList()))
               .publishedComments(
                   proto.getPublishedCommentList().stream()
                       .map(r -> GSON.fromJson(r, HumanComment.class))
                       .collect(toImmutableListMultimap(HumanComment::getCommitId, c -> c)))
-              .updateCount(proto.getUpdateCount());
+              .updateCount(proto.getUpdateCount())
+              .mergedOn(proto.getHasMergedOn() ? new Timestamp(proto.getMergedOnMillis()) : null);
       return b.build();
     }
 
-    private static <P extends MessageLite, T> T parseProtoFrom(
-        ProtoConverter<P, T> converter, ByteString byteString) {
-      P message = Protos.parseUnchecked(converter.getParser(), byteString);
-      return converter.fromProto(message);
-    }
-
     private static ChangeColumns toChangeColumns(Change.Id changeId, ChangeColumnsProto proto) {
       ChangeColumns.Builder b =
           ChangeColumns.builder()
@@ -735,6 +752,20 @@
       return b.build();
     }
 
+    private static ImmutableList<AttentionSetUpdate> toAllAttentionSetUpdates(
+        List<AttentionSetUpdateProto> protos) {
+      ImmutableList.Builder<AttentionSetUpdate> b = ImmutableList.builder();
+      for (AttentionSetUpdateProto proto : protos) {
+        b.add(
+            AttentionSetUpdate.createFromRead(
+                Instant.ofEpochMilli(proto.getTimestampMillis()),
+                Account.id(proto.getAccount()),
+                AttentionSetUpdate.Operation.valueOf(proto.getOperation()),
+                proto.getReason()));
+      }
+      return b.build();
+    }
+
     private static ImmutableList<AssigneeStatusUpdate> toAssigneeStatusUpdateList(
         List<AssigneeStatusUpdateProto> protos) {
       ImmutableList.Builder<AssigneeStatusUpdate> b = ImmutableList.builder();
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 52c551e..708212d 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -65,6 +65,7 @@
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.ServiceUserClassifier;
@@ -87,6 +88,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -809,21 +811,25 @@
         getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER);
     Set<AttentionSetUpdate> updates = new HashSet<>();
     for (Map.Entry<Account.Id, ReviewerStateInternal> reviewer : reviewers.entrySet()) {
-      // Only add new reviewers to the attention set.
-      if (reviewer.getValue().equals(ReviewerStateInternal.REVIEWER)
-          && !currentReviewers.contains(reviewer.getKey())) {
+      Account.Id reviewerId = reviewer.getKey();
+      ReviewerStateInternal reviewerState = reviewer.getValue();
+      // Only add new reviewers to the attention set. Also, don't add the owner because the owner
+      // can only be a "dummy" reviewer for legacy reasons.
+      if (reviewerState.equals(ReviewerStateInternal.REVIEWER)
+          && !currentReviewers.contains(reviewerId)
+          && !reviewerId.equals(getChange().getOwner())) {
         updates.add(
             AttentionSetUpdate.createForWrite(
-                reviewer.getKey(), AttentionSetUpdate.Operation.ADD, "Reviewer was added"));
+                reviewerId, AttentionSetUpdate.Operation.ADD, "Reviewer was added"));
       }
       boolean reviewerRemoved =
-          !reviewer.getValue().equals(ReviewerStateInternal.REVIEWER)
-              && currentReviewers.contains(reviewer.getKey());
-      boolean ccRemoved = reviewer.getValue().equals(ReviewerStateInternal.REMOVED);
+          !reviewerState.equals(ReviewerStateInternal.REVIEWER)
+              && currentReviewers.contains(reviewerId);
+      boolean ccRemoved = reviewerState.equals(ReviewerStateInternal.REMOVED);
       if (reviewerRemoved || ccRemoved) {
         updates.add(
             AttentionSetUpdate.createForWrite(
-                reviewer.getKey(), AttentionSetUpdate.Operation.REMOVE, "Reviewer/Cc was removed"));
+                reviewerId, AttentionSetUpdate.Operation.REMOVE, "Reviewer/Cc was removed"));
       }
     }
     addToPlannedAttentionSetUpdates(updates);
@@ -854,6 +860,22 @@
         AttentionSetUtil.additionsOnly(getNotes().getAttentionSet()).stream()
             .map(AttentionSetUpdate::account)
             .collect(Collectors.toSet());
+
+    // Current reviewers/ccs are the reviewers/ccs before the update + the new reviewers/ccs - the
+    // deleted reviewers/ccs.
+    Set<Account.Id> currentReviewers =
+        Stream.concat(
+                getNotes().getReviewers().all().stream(),
+                reviewers.entrySet().stream()
+                    .filter(r -> r.getValue().asReviewerState() != ReviewerState.REMOVED)
+                    .map(r -> r.getKey()))
+            .collect(Collectors.toSet());
+    currentReviewers.removeAll(
+        reviewers.entrySet().stream()
+            .filter(r -> r.getValue().asReviewerState() == ReviewerState.REMOVED)
+            .map(r -> r.getKey())
+            .collect(ImmutableSet.toImmutableSet()));
+
     for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) {
       if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
           && currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
@@ -881,11 +903,27 @@
         continue;
       }
 
+      // Don't add accounts that are not active in the change to the attention set.
+      if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
+          && !isActiveOnChange(currentReviewers, attentionSetUpdate.account())) {
+        continue;
+      }
+
       addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
     }
   }
 
   /**
+   * Returns whether {@code accountId} is active on a change based on the {@code currentReviewers}.
+   * Activity is defined as being a part of the reviewers, an uploader, or an owner of a change.
+   */
+  private boolean isActiveOnChange(Set<Account.Id> currentReviewers, Account.Id accountId) {
+    return currentReviewers.contains(accountId)
+        || getChange().getOwner().equals(accountId)
+        || getNotes().getCurrentPatchSet().uploader().equals(accountId);
+  }
+
+  /**
    * When set, default attention set rules are ignored (E.g, adding reviewers -> adds to attention
    * set, etc).
    */
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java b/java/com/google/gerrit/server/notedb/MissingMetaObjectException.java
similarity index 60%
copy from java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
copy to java/com/google/gerrit/server/notedb/MissingMetaObjectException.java
index 08d6ce7..7f9de0d 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
+++ b/java/com/google/gerrit/server/notedb/MissingMetaObjectException.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2019 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,12 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.change;
+package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.util.List;
+/** Separate exception type to throw if requested meta SHA1 is not available. */
+public class MissingMetaObjectException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
 
-public interface PluginDefinedAttributesFactory {
-  List<PluginDefinedInfo> create(ChangeData cd);
+  MissingMetaObjectException(String msg) {
+    super(msg);
+  }
 }
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 2e0214c..3f17a2e 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -16,19 +16,23 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.base.Throwables;
-import com.google.gerrit.common.Nullable;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.git.LockFailureException;
+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.metrics.Timer1;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryableAction.ActionType;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
@@ -37,13 +41,13 @@
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.merge.ResolveMerger;
 import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 /**
  * Utility class for creating an auto-merge commit of a merge commit.
@@ -67,96 +71,130 @@
  * is that these refs should never be deleted.
  */
 public class AutoMerger {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static final String AUTO_MERGE_MSG_PREFIX = "Auto-merge of ";
+
   @UsedAt(UsedAt.Project.GOOGLE)
   public static boolean cacheAutomerge(Config cfg) {
     return cfg.getBoolean("change", null, "cacheAutomerge", true);
   }
 
-  private final RetryHelper retryHelper;
+  private enum OperationType {
+    CACHE_LOAD,
+    IN_MEMORY_WRITE,
+    ON_DISK_WRITE
+  }
+
+  private final Counter1<OperationType> counter;
+  private final Timer1<OperationType> latency;
   private final PersonIdent gerritIdent;
   private final boolean save;
+  private final ThreeWayMergeStrategy configuredMergeStrategy;
 
   @Inject
   AutoMerger(
-      RetryHelper retryHelper,
+      MetricMaker metricMaker,
       @GerritServerConfig Config cfg,
       @GerritPersonIdent PersonIdent gerritIdent) {
-    this.retryHelper = retryHelper;
-    save = cacheAutomerge(cfg);
+    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());
+    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());
+    this.save = cacheAutomerge(cfg);
     this.gerritIdent = gerritIdent;
+    this.configuredMergeStrategy = MergeUtil.getMergeStrategy(cfg);
   }
 
   /**
-   * Creates an auto-merge commit of the parents of the given merge commit.
+   * Reads or creates an auto-merge commit of the parents of the given merge commit.
    *
-   * <p>In case of an exception the creation of the auto-merge commit is retried a few times. E.g.
-   * this allows the operation to succeed if a Git update fails due to a temporary issue.
+   * <p>The result is read from Git or computed in-memory and not written back to Git. This method
+   * exists for backwards compatibility only. All new changes have their auto-merge commits written
+   * transactionally when the change or patch set is created.
    *
    * @return auto-merge commit. Headers of the returned RevCommit are parsed.
    */
-  public RevCommit merge(
+  public RevCommit lookupFromGitOrMergeInMemory(
       Repository repo,
       RevWalk rw,
-      ObjectInserter ins,
-      RevCommit merge,
-      ThreeWayMergeStrategy mergeStrategy)
-      throws IOException {
-    try {
-      return retryHelper
-          .action(
-              ActionType.GIT_UPDATE,
-              "createAutoMerge",
-              () -> createAutoMergeCommit(repo, rw, ins, merge, mergeStrategy))
-          .call();
-    } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      Throwables.throwIfInstanceOf(e, IOException.class);
-      throw new IllegalStateException(e);
-    }
-  }
-
-  /**
-   * Creates an auto-merge commit of the parents of the given merge commit.
-   *
-   * @return auto-merge commit. Headers of the returned RevCommit are parsed.
-   */
-  private RevCommit createAutoMergeCommit(
-      Repository repo,
-      RevWalk rw,
-      ObjectInserter ins,
+      InMemoryInserter ins,
       RevCommit merge,
       ThreeWayMergeStrategy mergeStrategy)
       throws IOException {
     checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
+    Optional<RevCommit> existingCommit =
+        lookupCommit(repo, rw, RefNames.refsCacheAutomerge(merge.name()));
+    if (existingCommit.isPresent()) {
+      counter.increment(OperationType.CACHE_LOAD);
+      return existingCommit.get();
+    }
+    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)) {
+      return rw.parseCommit(createAutoMergeCommit(repo.getConfig(), rw, ins, merge, mergeStrategy));
+    }
+  }
 
-    InMemoryInserter tmpIns = null;
-    if (ins instanceof InMemoryInserter) {
-      // Caller gave us an in-memory inserter, so ensure anything we write from
-      // this method is visible to them.
-      tmpIns = (InMemoryInserter) ins;
-    } else if (!save) {
-      // If we don't plan on saving results, use a fully in-memory inserter.
-      // Using just a non-flushing wrapper is not sufficient, since in
-      // particular DfsInserter might try to write to storage after exceeding an
-      // internal buffer size.
-      tmpIns = new InMemoryInserter(rw.getObjectReader());
+  /**
+   * Creates an auto merge commit for the provided commit in case it is a merge commit. To be used
+   * whenever Gerrit creates new patch sets.
+   *
+   * <p>Callers need to include the returned {@link ReceiveCommand} in their ref transaction.
+   *
+   * @return A {@link ReceiveCommand} wrapped in an {@link Optional} to be used in a {@link
+   *     org.eclipse.jgit.lib.BatchRefUpdate}. {@link Optional#empty()} in case we don't need an
+   *     auto merge commit.
+   */
+  public Optional<ReceiveCommand> createAutoMergeCommitIfNecessary(
+      RepoView repoView, RevWalk rw, ObjectInserter ins, RevCommit maybeMergeCommit)
+      throws IOException {
+    if (maybeMergeCommit.getParentCount() != 2 || !save) {
+      logger.atFine().log("AutoMerge not required");
+      return Optional.empty();
     }
 
+    ObjectId autoMerge;
+    try (Timer1.Context ignored = latency.start(OperationType.ON_DISK_WRITE)) {
+      autoMerge =
+          createAutoMergeCommit(
+              repoView.getConfig(), rw, ins, maybeMergeCommit, 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())));
+  }
+
+  /**
+   * Creates an auto-merge commit of the parents of the given merge commit.
+   *
+   * @return auto-merge commit. Headers of the returned RevCommit are parsed.
+   */
+  private ObjectId createAutoMergeCommit(
+      Config repoConfig,
+      RevWalk rw,
+      ObjectInserter ins,
+      RevCommit merge,
+      ThreeWayMergeStrategy mergeStrategy)
+      throws IOException {
     rw.parseHeaders(merge);
-    String refName = RefNames.refsCacheAutomerge(merge.name());
-    Ref ref = repo.getRefDatabase().exactRef(refName);
-    if (ref != null && ref.getObjectId() != null) {
-      RevObject obj = rw.parseAny(ref.getObjectId());
-      if (obj instanceof RevCommit) {
-        return (RevCommit) obj;
-      }
-      return commit(repo, rw, tmpIns, ins, refName, obj, merge);
-    }
-
-    ResolveMerger m = (ResolveMerger) mergeStrategy.newMerger(repo, true);
+    ResolveMerger m = (ResolveMerger) mergeStrategy.newMerger(ins, repoConfig);
     DirCache dc = DirCache.newInCore();
     m.setDirCache(dc);
-    m.setObjectInserter(tmpIns == null ? new NonFlushingWrapper(ins) : tmpIns);
+    // If we don't plan on saving results, use a fully in-memory inserter.
+    // Using just a non-flushing wrapper is not sufficient, since in particular DfsInserter might
+    // try to write to storage after exceeding an internal buffer size.
+    m.setObjectInserter(ins instanceof InMemoryInserter ? new NonFlushingWrapper(ins) : ins);
 
     boolean couldMerge = m.merge(merge.getParents());
 
@@ -176,18 +214,6 @@
               m.getMergeResults());
     }
 
-    return commit(repo, rw, tmpIns, ins, refName, treeId, merge);
-  }
-
-  private RevCommit commit(
-      Repository repo,
-      RevWalk rw,
-      @Nullable InMemoryInserter tmpIns,
-      ObjectInserter ins,
-      String refName,
-      ObjectId tree,
-      RevCommit merge)
-      throws IOException {
     rw.parseHeaders(merge);
     // For maximum stability, choose a single ident using the committer time of
     // the input commit, using the server name and timezone.
@@ -197,50 +223,34 @@
     CommitBuilder cb = new CommitBuilder();
     cb.setAuthor(ident);
     cb.setCommitter(ident);
-    cb.setTreeId(tree);
-    cb.setMessage("Auto-merge of " + merge.name() + '\n');
+    cb.setTreeId(treeId);
+    cb.setMessage(AUTO_MERGE_MSG_PREFIX + merge.name() + '\n');
     for (RevCommit p : merge.getParents()) {
       cb.addParentId(p);
     }
 
-    if (!save) {
-      checkArgument(tmpIns != null);
-      try (ObjectReader tmpReader = tmpIns.newReader();
+    if (ins instanceof InMemoryInserter) {
+      // When using an InMemoryInserter we need to read back the values from that inserter because
+      // they are not available.
+      try (ObjectReader tmpReader = ins.newReader();
           RevWalk tmpRw = new RevWalk(tmpReader)) {
-        return tmpRw.parseCommit(tmpIns.insert(cb));
+        return tmpRw.parseCommit(ins.insert(cb));
       }
     }
 
-    checkArgument(tmpIns == null);
-    checkArgument(!(ins instanceof InMemoryInserter));
-    ObjectId commitId = ins.insert(cb);
-    ins.flush();
+    return rw.parseCommit(ins.insert(cb));
+  }
 
-    RefUpdate ru = repo.updateRef(refName);
-    ru.setNewObjectId(commitId);
-    ru.disableRefLog();
-    switch (ru.forceUpdate()) {
-      case FAST_FORWARD:
-      case FORCED:
-      case NEW:
-      case NO_CHANGE:
-        return rw.parseCommit(commitId);
-      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 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 {
diff --git a/java/com/google/gerrit/server/patch/BaseCommitUtil.java b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
new file mode 100644
index 0000000..7c06a62
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
@@ -0,0 +1,121 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.InMemoryInserter;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** A utility class for computing the base commit / parent for a specific patchset commit. */
+class BaseCommitUtil {
+  private final AutoMerger autoMerger;
+  private final ThreeWayMergeStrategy mergeStrategy;
+  private final GitRepositoryManager repoManager;
+
+  @Inject
+  BaseCommitUtil(AutoMerger am, @GerritServerConfig Config cfg, GitRepositoryManager repoManager) {
+    this.autoMerger = am;
+    this.mergeStrategy = MergeUtil.getMergeStrategy(cfg);
+    this.repoManager = repoManager;
+  }
+
+  RevObject getBaseCommit(Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
+      throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        InMemoryInserter ins = new InMemoryInserter(repo);
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      return getParentCommit(repo, ins, rw, parentNum, newCommit);
+    }
+  }
+
+  /**
+   * Returns the number of parent commits of the commit represented by the commitId parameter.
+   *
+   * @param project a specific git repository.
+   * @param commitId 20 bytes commitId SHA-1 hash.
+   * @return an integer representing the number of parents of the designated commit.
+   */
+  int getNumParents(Project.NameKey project, ObjectId commitId) throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      RevCommit current = rw.parseCommit(commitId);
+      return current.getParentCount();
+    }
+  }
+
+  /**
+   * Returns the parent commit Object of the commit represented by the commitId parameter.
+   *
+   * @param repo a git repository.
+   * @param ins a git object inserter in the database.
+   * @param rw a {@link RevWalk} object of the repository.
+   * @param parentNum used to identify the parent number for merge commits. If parentNum is null and
+   *     {@code commitId} has two parents, the auto-merge commit will be returned. If {@code
+   *     commitId} has a single parent, it will be returned.
+   * @param commitId 20 bytes commitId SHA-1 hash.
+   * @return Returns the parent commit of the commit represented by the commitId parameter. Note
+   *     that auto-merge is not supported for commits having more than two parents.
+   */
+  RevObject getParentCommit(
+      Repository repo,
+      InMemoryInserter ins,
+      RevWalk rw,
+      @Nullable Integer parentNum,
+      ObjectId commitId)
+      throws IOException {
+    RevCommit current = rw.parseCommit(commitId);
+    switch (current.getParentCount()) {
+      case 0:
+        return rw.parseAny(emptyTree(ins));
+      case 1:
+        return current.getParent(0);
+      default:
+        if (parentNum != null) {
+          RevCommit r = current.getParent(parentNum - 1);
+          rw.parseBody(r);
+          return r;
+        }
+        // Only support auto-merge for 2 parents, not octopus merges
+        if (current.getParentCount() == 2) {
+          return autoMerger.lookupFromGitOrMergeInMemory(repo, rw, ins, current, mergeStrategy);
+        }
+        return null;
+    }
+  }
+
+  private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
+    ObjectId id = ins.insert(Constants.OBJ_TREE, new byte[] {});
+    ins.flush();
+    return id;
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/ComparisonType.java b/java/com/google/gerrit/server/patch/ComparisonType.java
index 260c507..eca2658 100644
--- a/java/com/google/gerrit/server/patch/ComparisonType.java
+++ b/java/com/google/gerrit/server/patch/ComparisonType.java
@@ -16,34 +16,40 @@
 
 import static com.google.gerrit.server.ioutil.BasicSerialization.readVarInt32;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32;
-import static java.util.Objects.requireNonNull;
 
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.server.cache.proto.Cache.FileDiffOutputProto;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.util.Optional;
 
-public class ComparisonType {
+/** Relation between the old and new commits used in the diff. */
+@AutoValue
+public abstract class ComparisonType {
 
-  /** 1-based parent */
-  private final Integer parentNum;
+  /**
+   * 1-based parent. Available if the old commit is the parent of the new commit and old commit is
+   * not the auto-merge.
+   */
+  abstract Optional<Integer> parentNum();
 
-  private final boolean autoMerge;
+  abstract boolean autoMerge();
 
   public static ComparisonType againstOtherPatchSet() {
-    return new ComparisonType(null, false);
+    return new AutoValue_ComparisonType(Optional.empty(), false);
   }
 
   public static ComparisonType againstParent(int parentNum) {
-    return new ComparisonType(parentNum, false);
+    return new AutoValue_ComparisonType(Optional.of(parentNum), false);
   }
 
   public static ComparisonType againstAutoMerge() {
-    return new ComparisonType(null, true);
+    return new AutoValue_ComparisonType(Optional.empty(), true);
   }
 
-  private ComparisonType(Integer parentNum, boolean autoMerge) {
-    this.parentNum = parentNum;
-    this.autoMerge = autoMerge;
+  private static ComparisonType create(Optional<Integer> parent, boolean automerge) {
+    return new AutoValue_ComparisonType(parent, automerge);
   }
 
   public boolean isAgainstParentOrAutoMerge() {
@@ -51,27 +57,43 @@
   }
 
   public boolean isAgainstParent() {
-    return parentNum != null;
+    return parentNum().isPresent();
   }
 
   public boolean isAgainstAutoMerge() {
-    return autoMerge;
+    return autoMerge();
   }
 
-  public int getParentNum() {
-    requireNonNull(parentNum);
-    return parentNum;
+  public Optional<Integer> getParentNum() {
+    return parentNum();
   }
 
   void writeTo(OutputStream out) throws IOException {
-    writeVarInt32(out, parentNum != null ? parentNum : 0);
-    writeVarInt32(out, autoMerge ? 1 : 0);
+    writeVarInt32(out, isAgainstParent() ? parentNum().get() : 0);
+    writeVarInt32(out, autoMerge() ? 1 : 0);
   }
 
   static ComparisonType readFrom(InputStream in) throws IOException {
     int p = readVarInt32(in);
-    Integer parentNum = p > 0 ? p : null;
+    Optional<Integer> parentNum = p > 0 ? Optional.of(p) : Optional.empty();
     boolean autoMerge = readVarInt32(in) != 0;
-    return new ComparisonType(parentNum, autoMerge);
+    return create(parentNum, autoMerge);
+  }
+
+  public FileDiffOutputProto.ComparisonType toProto() {
+    FileDiffOutputProto.ComparisonType.Builder builder =
+        FileDiffOutputProto.ComparisonType.newBuilder().setAutoMerge(autoMerge());
+    if (parentNum().isPresent()) {
+      builder.setParentNum(parentNum().get());
+    }
+    return builder.build();
+  }
+
+  public static ComparisonType fromProto(FileDiffOutputProto.ComparisonType proto) {
+    Optional<Integer> parentNum = Optional.empty();
+    if (proto.hasField(FileDiffOutputProto.ComparisonType.getDescriptor().findFieldByNumber(1))) {
+      parentNum = Optional.of(proto.getParentNum());
+    }
+    return create(parentNum, proto.getAutoMerge());
   }
 }
diff --git a/java/com/google/gerrit/server/patch/DiffExecutor.java b/java/com/google/gerrit/server/patch/DiffExecutor.java
index 072c2da..63d5c50 100644
--- a/java/com/google/gerrit/server/patch/DiffExecutor.java
+++ b/java/com/google/gerrit/server/patch/DiffExecutor.java
@@ -16,6 +16,7 @@
 
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
+import com.google.gerrit.server.patch.filediff.PatchListLoader;
 import com.google.inject.BindingAnnotation;
 import java.lang.annotation.Retention;
 import java.util.concurrent.ExecutorService;
diff --git a/java/com/google/gerrit/server/patch/DiffMappings.java b/java/com/google/gerrit/server/patch/DiffMappings.java
index 921d66e..57132f8 100644
--- a/java/com/google/gerrit/server/patch/DiffMappings.java
+++ b/java/com/google/gerrit/server/patch/DiffMappings.java
@@ -15,12 +15,17 @@
 package com.google.gerrit.server.patch;
 
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.server.patch.GitPositionTransformer.FileMapping;
 import com.google.gerrit.server.patch.GitPositionTransformer.Mapping;
 import com.google.gerrit.server.patch.GitPositionTransformer.Range;
 import com.google.gerrit.server.patch.GitPositionTransformer.RangeMapping;
+import com.google.gerrit.server.patch.filediff.Edit;
+import com.google.gerrit.server.patch.filediff.FileEdits;
+import java.util.List;
 
 /** Mappings derived from diffs. */
 public class DiffMappings {
@@ -33,31 +38,47 @@
     return Mapping.create(fileMapping, rangeMappings);
   }
 
-  private static FileMapping toFileMapping(PatchListEntry patchListEntry) {
-    switch (patchListEntry.getChangeType()) {
+  public static Mapping toMapping(FileEdits fileEdits) {
+    FileMapping fileMapping = FileMapping.forFile(fileEdits.oldPath(), fileEdits.newPath());
+    ImmutableSet<RangeMapping> rangeMappings = toRangeMappings(fileEdits.edits());
+    return Mapping.create(fileMapping, rangeMappings);
+  }
+
+  private static FileMapping toFileMapping(PatchListEntry ple) {
+    return toFileMapping(ple.getChangeType(), ple.getOldName(), ple.getNewName());
+  }
+
+  private static FileMapping toFileMapping(
+      Patch.ChangeType changeType, String oldName, String newName) {
+    switch (changeType) {
       case ADDED:
-        return FileMapping.forAddedFile(patchListEntry.getNewName());
+        return FileMapping.forAddedFile(newName);
       case MODIFIED:
       case REWRITE:
-        return FileMapping.forModifiedFile(patchListEntry.getNewName());
+        return FileMapping.forModifiedFile(newName);
       case DELETED:
         // Name of deleted file is mentioned as newName.
-        return FileMapping.forDeletedFile(patchListEntry.getNewName());
+        return FileMapping.forDeletedFile(newName);
       case RENAMED:
       case COPIED:
-        return FileMapping.forRenamedFile(patchListEntry.getOldName(), patchListEntry.getNewName());
+        return FileMapping.forRenamedFile(oldName, newName);
       default:
-        throw new IllegalStateException("Unmapped diff type: " + patchListEntry.getChangeType());
+        throw new IllegalStateException("Unmapped diff type: " + changeType);
     }
   }
 
   private static ImmutableSet<RangeMapping> toRangeMappings(PatchListEntry patchListEntry) {
-    return patchListEntry.getEdits().stream()
+    return toRangeMappings(
+        patchListEntry.getEdits().stream().map(Edit::fromJGitEdit).collect(toList()));
+  }
+
+  private static ImmutableSet<RangeMapping> toRangeMappings(List<Edit> edits) {
+    return edits.stream()
         .map(
             edit ->
                 RangeMapping.create(
-                    Range.create(edit.getBeginA(), edit.getEndA()),
-                    Range.create(edit.getBeginB(), edit.getEndB())))
+                    Range.create(edit.beginA(), edit.endA()),
+                    Range.create(edit.beginB(), edit.endB())))
         .collect(toImmutableSet());
   }
 }
diff --git a/java/com/google/gerrit/server/patch/DiffNotAvailableException.java b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
new file mode 100644
index 0000000..ea92a99
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
@@ -0,0 +1,38 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+
+/**
+ * Thrown by the diff caches - the {@link GitModifiedFilesCache} and the {@link ModifiedFilesCache},
+ * if the implementations failed to retrieve the modified files between the 2 commits.
+ */
+public class DiffNotAvailableException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public DiffNotAvailableException(Throwable cause) {
+    super(cause);
+  }
+
+  public DiffNotAvailableException(String message) {
+    super(message);
+  }
+
+  public DiffNotAvailableException(String message, Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
new file mode 100644
index 0000000..93aefff
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -0,0 +1,121 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * An interface for all file diff related operations. Clients should use this interface to request:
+ *
+ * <ul>
+ *   <li>The list of modified files between two commits.
+ *   <li>The list of modified files between a commit and its parent or the auto-merge.
+ *   <li>The detailed file diff for a single file path.
+ *   <li>The Intra-line diffs for a single file path (TODO:ghareeb).
+ * </ul>
+ */
+public interface DiffOperations {
+
+  /**
+   * Returns the list of added, deleted or modified files between a commit against its base. The
+   * {@link Patch#COMMIT_MSG} and {@link Patch#MERGE_LIST} (for merge commits) are also returned.
+   *
+   * <p>If parentNum is set, it is used as the old commit in the diff. Otherwise, if the {@code
+   * newCommit} has only one parent, it is used as base. If {@code newCommit} has two parents, the
+   * auto-merge commit is computed and used as base. The auto-merge for more than two parents is not
+   * supported.
+   *
+   * @param project a project name representing a git repository.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @param parentNum integer specifying which parent to use as base. If null, the only parent will
+   *     be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @return the list of modified files between the two commits.
+   * @throws DiffNotAvailableException if auto-merge is requested for a commit having more than two
+   *     parents, if the {@code newCommit} could not be parsed for extracting the base commit, or if
+   *     an internal error occurred in Git while evaluating the diff.
+   */
+  Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
+      Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
+      throws DiffNotAvailableException;
+
+  /**
+   * Returns the list of added, deleted or modified files between two commits (patchsets). The
+   * commit message and merge list (for merge commits) are also returned.
+   *
+   * @param project a project name representing a git repository.
+   * @param oldCommit 20 bytes SHA-1 of the old commit used in the diff.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @return the list of modified files between the two commits.
+   * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
+   *     diff.
+   */
+  Map<String, FileDiffOutput> listModifiedFiles(
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit)
+      throws DiffNotAvailableException;
+
+  /**
+   * Returns the diff for a single file between a patchset commit against its parent or the
+   * auto-merge commit. For deleted files, the {@code fileName} parameter should contain the old
+   * name of the file. This method will return {@link FileDiffOutput#empty(String, ObjectId,
+   * ObjectId)} if the requested file identified by {@code fileName} has unchanged content or does
+   * not exist at both commits.
+   *
+   * @param project a project name representing a git repository.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @param parentNum integer specifying which parent to use as base. If null, the only parent will
+   *     be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @param fileName the file name for which the diff should be evaluated.
+   * @param whitespace preference controlling whitespace effect in diff computation.
+   * @return the diff for the single file between the two commits.
+   * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
+   *     diff, or if an exception happened while parsing the base commit.
+   */
+  FileDiffOutput getModifiedFileAgainstParent(
+      Project.NameKey project,
+      ObjectId newCommit,
+      @Nullable Integer parentNum,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace)
+      throws DiffNotAvailableException;
+
+  /**
+   * Returns the diff for a single file between two patchset commits. For deleted files, the {@code
+   * fileName} parameter should contain the old name of the file. This method will return {@link
+   * FileDiffOutput#empty(String, ObjectId, ObjectId)} if the requested file identified by {@code
+   * fileName} has unchanged content or does not exist at both commits.
+   *
+   * @param project a project name representing a git repository.
+   * @param oldCommit 20 bytes SHA-1 of the old commit used in the diff.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @param fileName the file name for which the diff should be evaluated.
+   * @param whitespace preference controlling whitespace effect in diff computation.
+   * @return the diff for the single file between the two commits.
+   * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
+   *     diff.
+   */
+  FileDiffOutput getModifiedFile(
+      Project.NameKey project,
+      ObjectId oldCommit,
+      ObjectId newCommit,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace)
+      throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
new file mode 100644
index 0000000..6217239
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -0,0 +1,411 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
+import static com.google.gerrit.entities.Patch.MERGE_LIST;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.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;
+import com.google.gerrit.server.patch.filediff.FileDiffCache;
+import com.google.gerrit.server.patch.filediff.FileDiffCacheImpl;
+import com.google.gerrit.server.patch.filediff.FileDiffCacheKey;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import 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.
+ */
+public class DiffOperationsImpl implements DiffOperations {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final int RENAME_SCORE = 60;
+  private static final DiffAlgorithm DEFAULT_DIFF_ALGORITHM = DiffAlgorithm.HISTOGRAM;
+  private static final Whitespace DEFAULT_WHITESPACE = Whitespace.IGNORE_NONE;
+
+  private final ModifiedFilesCache modifiedFilesCache;
+  private final FileDiffCache fileDiffCache;
+  private final BaseCommitUtil baseCommitUtil;
+  private final long timeoutMillis;
+  private final ExecutorService diffExecutor;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(DiffOperations.class).to(DiffOperationsImpl.class);
+        install(GitModifiedFilesCacheImpl.module());
+        install(ModifiedFilesCacheImpl.module());
+        install(GitFileDiffCacheImpl.module());
+        install(FileDiffCacheImpl.module());
+      }
+    };
+  }
+
+  @Inject
+  public DiffOperationsImpl(
+      ModifiedFilesCache modifiedFilesCache,
+      FileDiffCache fileDiffCache,
+      BaseCommitUtil baseCommit,
+      @DiffExecutor ExecutorService executor,
+      @GerritServerConfig Config cfg) {
+    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 {
+    try {
+      DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
+      return listModifiedFilesWithTimeout(diffParams);
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(
+          "Failed to evaluate the parent/base commit for commit " + newCommit, e);
+    }
+  }
+
+  @Override
+  public Map<String, FileDiffOutput> listModifiedFiles(
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit)
+      throws DiffNotAvailableException {
+    DiffParameters params =
+        DiffParameters.builder()
+            .project(project)
+            .newCommit(newCommit)
+            .baseCommit(oldCommit)
+            .comparisonType(ComparisonType.againstOtherPatchSet())
+            .build();
+    return listModifiedFilesWithTimeout(params);
+  }
+
+  @Override
+  public FileDiffOutput getModifiedFileAgainstParent(
+      Project.NameKey project,
+      ObjectId newCommit,
+      @Nullable Integer parent,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace)
+      throws DiffNotAvailableException {
+    try {
+      DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
+      FileDiffCacheKey key =
+          createFileDiffCacheKey(project, diffParams.baseCommit(), newCommit, fileName, whitespace);
+      return getModifiedFileWithTimeout(key, diffParams);
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(
+          "Failed to evaluate the parent/base commit for commit " + newCommit, e);
+    }
+  }
+
+  @Override
+  public FileDiffOutput getModifiedFile(
+      Project.NameKey project,
+      ObjectId oldCommit,
+      ObjectId newCommit,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace)
+      throws DiffNotAvailableException {
+    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);
+    }
+  }
+
+  private ImmutableMap<String, FileDiffOutput> getModifiedFiles(DiffParameters diffParams)
+      throws DiffNotAvailableException {
+    try {
+      Project.NameKey project = diffParams.project();
+      ObjectId newCommit = diffParams.newCommit();
+      ObjectId oldCommit = diffParams.baseCommit();
+      ComparisonType cmp = diffParams.comparisonType();
+
+      ImmutableList<ModifiedFile> modifiedFiles =
+          modifiedFilesCache.get(createModifiedFilesKey(project, oldCommit, newCommit));
+
+      List<FileDiffCacheKey> fileCacheKeys = new ArrayList<>();
+      fileCacheKeys.add(
+          createFileDiffCacheKey(
+              project, oldCommit, newCommit, COMMIT_MSG, /* whitespace= */ null));
+
+      if (cmp.isAgainstAutoMerge() || isMergeAgainstParent(cmp, project, newCommit)) {
+        fileCacheKeys.add(
+            createFileDiffCacheKey(
+                project, oldCommit, newCommit, MERGE_LIST, /*whitespace = */ null));
+      }
+
+      if (diffParams.skipFiles() == null) {
+        modifiedFiles.stream()
+            .map(
+                entity ->
+                    createFileDiffCacheKey(
+                        project,
+                        oldCommit,
+                        newCommit,
+                        entity.newPath().isPresent()
+                            ? entity.newPath().get()
+                            : entity.oldPath().get(),
+                        /* whitespace= */ null))
+            .forEach(fileCacheKeys::add);
+      }
+      return getModifiedFilesForKeys(fileCacheKeys);
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  private ImmutableMap<String, FileDiffOutput> getModifiedFilesForKeys(List<FileDiffCacheKey> keys)
+      throws DiffNotAvailableException {
+    ImmutableMap.Builder<String, FileDiffOutput> files = ImmutableMap.builder();
+    ImmutableMap<FileDiffCacheKey, FileDiffOutput> fileDiffs = fileDiffCache.getAll(keys);
+
+    for (FileDiffOutput fileDiffOutput : fileDiffs.values()) {
+      if (fileDiffOutput.isEmpty() || allDueToRebase(fileDiffOutput)) {
+        continue;
+      }
+      if (fileDiffOutput.changeType() == ChangeType.DELETED) {
+        files.put(fileDiffOutput.oldPath().get(), fileDiffOutput);
+      } else {
+        files.put(fileDiffOutput.newPath().get(), fileDiffOutput);
+      }
+    }
+    return files.build();
+  }
+
+  private static boolean allDueToRebase(FileDiffOutput fileDiffOutput) {
+    return fileDiffOutput.allEditsDueToRebase()
+        && (!(fileDiffOutput.changeType() == ChangeType.RENAMED
+            || fileDiffOutput.changeType() == ChangeType.COPIED));
+  }
+
+  private boolean isMergeAgainstParent(ComparisonType cmp, Project.NameKey project, ObjectId commit)
+      throws IOException {
+    return (cmp.isAgainstParent() && baseCommitUtil.getNumParents(project, commit) > 1);
+  }
+
+  private static ModifiedFilesCacheKey createModifiedFilesKey(
+      Project.NameKey project, ObjectId aCommit, ObjectId bCommit) {
+    return ModifiedFilesCacheKey.builder()
+        .project(project)
+        .aCommit(aCommit)
+        .bCommit(bCommit)
+        .renameScore(RENAME_SCORE)
+        .build();
+  }
+
+  private static FileDiffCacheKey createFileDiffCacheKey(
+      Project.NameKey project,
+      ObjectId aCommit,
+      ObjectId bCommit,
+      String newPath,
+      @Nullable Whitespace whitespace) {
+    whitespace = whitespace == null ? DEFAULT_WHITESPACE : whitespace;
+    return FileDiffCacheKey.builder()
+        .project(project)
+        .oldCommit(aCommit)
+        .newCommit(bCommit)
+        .newFilePath(newPath)
+        .renameScore(RENAME_SCORE)
+        .diffAlgorithm(DEFAULT_DIFF_ALGORITHM)
+        .whitespace(whitespace)
+        .build();
+  }
+
+  /**
+   * 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();
+
+    abstract ObjectId baseCommit();
+
+    abstract ComparisonType comparisonType();
+
+    @Nullable
+    abstract Integer parent();
+
+    /** Compute the diff for {@value Patch#COMMIT_MSG} and {@link Patch#MERGE_LIST} only. */
+    @Nullable
+    abstract Boolean skipFiles();
+
+    static Builder builder() {
+      return new AutoValue_DiffOperationsImpl_DiffParameters.Builder();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+
+      abstract Builder project(Project.NameKey project);
+
+      abstract Builder newCommit(ObjectId newCommit);
+
+      abstract Builder baseCommit(ObjectId baseCommit);
+
+      abstract Builder parent(@Nullable Integer parent);
+
+      abstract Builder skipFiles(@Nullable Boolean skipFiles);
+
+      abstract Builder comparisonType(ComparisonType comparisonType);
+
+      public abstract DiffParameters build();
+    }
+  }
+
+  /** Compute Diff parameters - the base commit and the comparison type - using the input args. */
+  private DiffParameters computeDiffParameters(
+      Project.NameKey project, ObjectId newCommit, Integer parent) throws IOException {
+    DiffParameters.Builder result =
+        DiffParameters.builder().project(project).newCommit(newCommit).parent(parent);
+    if (parent != null) {
+      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
+      result.comparisonType(ComparisonType.againstParent(parent));
+      return result.build();
+    }
+    int numParents = baseCommitUtil.getNumParents(project, newCommit);
+    if (numParents == 1) {
+      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
+      result.comparisonType(ComparisonType.againstParent(1));
+      return result.build();
+    }
+    if (numParents > 2) {
+      logger.atFine().log(
+          "Diff against auto-merge for merge commits "
+              + "with more than two parents is not supported. Commit "
+              + newCommit
+              + " has "
+              + numParents
+              + " parents. Falling back to the diff against the first parent.");
+      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, 1).getId());
+      result.comparisonType(ComparisonType.againstParent(1));
+      result.skipFiles(true);
+    } else {
+      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, null));
+      result.comparisonType(ComparisonType.againstAutoMerge());
+    }
+    return result.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffUtil.java b/java/com/google/gerrit/server/patch/DiffUtil.java
new file mode 100644
index 0000000..1e88f9f
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffUtil.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+package com.google.gerrit.server.patch;
+
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A utility class used by the diff cache interfaces {@link GitModifiedFilesCache} and {@link
+ * ModifiedFilesCache}.
+ */
+public class DiffUtil {
+
+  /**
+   * Returns the Git tree object ID pointed to by the commitId parameter.
+   *
+   * @param rw a {@link RevWalk} of an opened repository that is used to walk the commit graph.
+   * @param commitId 20 bytes commitId SHA-1 hash.
+   * @return Git tree object ID pointed to by the commitId.
+   */
+  public static ObjectId getTreeId(RevWalk rw, ObjectId commitId) throws IOException {
+    RevCommit current = rw.parseCommit(commitId);
+    return current.getTree().getId();
+  }
+
+  /**
+   * Returns the RevCommit object given the 20 bytes commitId SHA-1 hash.
+   *
+   * @param rw a {@link RevWalk} of an opened repository that is used to walk the commit graph.
+   * @param commitId 20 bytes commitId SHA-1 hash
+   * @return The RevCommit representing the commit in Git
+   * @throws IOException a pack file or loose object could not be read while parsing the commits.
+   */
+  public static RevCommit getRevCommit(RevWalk rw, ObjectId commitId) throws IOException {
+    return rw.parseCommit(commitId);
+  }
+
+  /**
+   * Returns true if the commitA and commitB parameters are parent/child, if they have a common
+   * parent, or if any of them is a root or merge commit.
+   */
+  public static boolean areRelated(RevCommit commitA, RevCommit commitB) {
+    return commitA == null
+        || isRootOrMergeCommit(commitA)
+        || isRootOrMergeCommit(commitB)
+        || areParentAndChild(commitA, commitB)
+        || haveCommonParent(commitA, commitB);
+  }
+
+  public static int stringSize(String str) {
+    if (str != null) {
+      // each character in the string occupies 2 bytes. Ignoring the fixed overhead for the string
+      // (length, offset and hash code) since they are negligible and do not affect the comparison
+      // of 2 strings.
+      return str.length() * 2;
+    }
+    return 0;
+  }
+
+  private static boolean isRootOrMergeCommit(RevCommit commit) {
+    return commit.getParentCount() != 1;
+  }
+
+  private static boolean areParentAndChild(RevCommit commitA, RevCommit commitB) {
+    return ObjectId.isEqual(commitA.getParent(0), commitB)
+        || ObjectId.isEqual(commitB.getParent(0), commitA);
+  }
+
+  private static boolean haveCommonParent(RevCommit commitA, RevCommit commitB) {
+    return ObjectId.isEqual(commitA.getParent(0), commitB.getParent(0));
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/FilePathAdapter.java b/java/com/google/gerrit/server/patch/FilePathAdapter.java
new file mode 100644
index 0000000..ccd1466
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/FilePathAdapter.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import com.google.gerrit.entities.Patch.ChangeType;
+import java.util.Optional;
+
+/**
+ * Adapter for old/new paths of the new diff cache to the old diff cache representation. This is
+ * needed for backward compatibility with all old diff cache callers.
+ *
+ * <p>TODO(ghareeb): It's better to revisit this logic and update all diff cache callers to use the
+ * new diff cache output directly.
+ */
+public class FilePathAdapter {
+  private FilePathAdapter() {}
+
+  /**
+   * Converts the old file path of the new diff cache output to the old diff cache representation.
+   */
+  public static String getOldPath(Optional<String> oldName, ChangeType changeType) {
+    switch (changeType) {
+      case DELETED:
+      case ADDED:
+      case MODIFIED:
+      case REWRITE:
+        return null;
+      case COPIED:
+      case RENAMED:
+        return oldName.get();
+      default:
+        throw new IllegalArgumentException("Unsupported type " + changeType);
+    }
+  }
+
+  /**
+   * Converts the new file path of the new diff cache output to the old diff cache representation.
+   */
+  public static String getNewPath(
+      Optional<String> oldName, Optional<String> newName, ChangeType changeType) {
+    switch (changeType) {
+      case DELETED:
+        return oldName.get();
+      case ADDED:
+      case MODIFIED:
+      case REWRITE:
+      case COPIED:
+      case RENAMED:
+        return newName.get();
+      default:
+        throw new IllegalArgumentException("Unsupported type " + changeType);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/GitPositionTransformer.java b/java/com/google/gerrit/server/patch/GitPositionTransformer.java
index d890bc2..f33d302 100644
--- a/java/com/google/gerrit/server/patch/GitPositionTransformer.java
+++ b/java/com/google/gerrit/server/patch/GitPositionTransformer.java
@@ -330,6 +330,11 @@
       return new AutoValue_GitPositionTransformer_FileMapping(
           Optional.of(oldPath), Optional.of(newPath));
     }
+
+    /** Creates a {@link FileMapping} using the old and new paths. */
+    public static FileMapping forFile(Optional<String> oldPath, Optional<String> newPath) {
+      return new AutoValue_GitPositionTransformer_FileMapping(oldPath, newPath);
+    }
   }
 
   /**
@@ -514,6 +519,15 @@
     }
 
     /**
+     * Returns the original underlying entity.
+     *
+     * @return the original instance of {@code T}
+     */
+    public T getEntity() {
+      return entity;
+    }
+
+    /**
      * Returns an updated version of the entity to which the internally stored {@link Position} was
      * written back to.
      *
diff --git a/java/com/google/gerrit/server/patch/MagicFile.java b/java/com/google/gerrit/server/patch/MagicFile.java
index aa6b11f..e42dd8c 100644
--- a/java/com/google/gerrit/server/patch/MagicFile.java
+++ b/java/com/google/gerrit/server/patch/MagicFile.java
@@ -93,7 +93,7 @@
           }
         default:
           int uninterestingParent =
-              comparisonType.isAgainstParent() ? comparisonType.getParentNum() : 1;
+              comparisonType.isAgainstParent() ? comparisonType.getParentNum().get() : 1;
 
           b.append("Merge List:\n\n");
           for (RevCommit commit : MergeListBuilder.build(rw, c, uninterestingParent)) {
diff --git a/java/com/google/gerrit/server/patch/PatchList.java b/java/com/google/gerrit/server/patch/PatchList.java
index 28f61d3..cb95553 100644
--- a/java/com/google/gerrit/server/patch/PatchList.java
+++ b/java/com/google/gerrit/server/patch/PatchList.java
@@ -24,8 +24,10 @@
 import static org.eclipse.jgit.lib.ObjectIdSerializer.writeWithoutMarker;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.git.ObjectIds;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -49,8 +51,42 @@
   static final Comparator<String> FILE_PATH_CMP =
       Comparator.comparing(Patch::isMagic).reversed().thenComparing(Comparator.naturalOrder());
 
+  /**
+   * We use the ChangeType comparator for a rare case when PatchList contains two entries for the
+   * same file, e.g. {ADDED, DELETED}. We return a single entry according to the following order.
+   * Check the following bug for an example case:
+   * https://bugs.chromium.org/p/gerrit/issues/detail?id=13914.
+   */
+  @VisibleForTesting
+  static class ChangeTypeCmp implements Comparator<ChangeType> {
+    static final List<ChangeType> order =
+        ImmutableList.of(
+            ChangeType.ADDED,
+            ChangeType.RENAMED,
+            ChangeType.MODIFIED,
+            ChangeType.COPIED,
+            ChangeType.REWRITE,
+            ChangeType.DELETED);
+
+    @Override
+    public int compare(ChangeType o1, ChangeType o2) {
+      int idx1 = priority(o1);
+      int idx2 = priority(o2);
+      return idx1 - idx2;
+    }
+
+    private int priority(ChangeType changeType) {
+      int idx = order.indexOf(changeType);
+      // Return least priority if the element is not in the order list.
+      return idx == -1 ? order.size() : idx;
+    }
+  }
+
+  @VisibleForTesting static final Comparator<ChangeType> CHANGE_TYPE_CMP = new ChangeTypeCmp();
+
   private static final Comparator<PatchListEntry> PATCH_CMP =
-      Comparator.comparing(PatchListEntry::getNewName, FILE_PATH_CMP);
+      Comparator.comparing(PatchListEntry::getNewName, FILE_PATH_CMP)
+          .thenComparing(PatchListEntry::getChangeType, CHANGE_TYPE_CMP);
 
   @Nullable private transient ObjectId oldId;
   private transient ObjectId newId;
@@ -121,12 +157,25 @@
 
   /** Find an entry by name, returning an empty entry if not present. */
   public PatchListEntry get(String fileName) {
-    final int index = search(fileName);
-    return 0 <= index ? patches[index] : PatchListEntry.empty(fileName);
+    int index = search(fileName);
+    if (index >= 0) {
+      return patches[index];
+    }
+    // If index is negative, it marks the insertion point of the object in the list.
+    // index = (-(insertion point) - 1).
+    // Since we use the ChangeType in the comparison, the object that we are using in the lookup
+    // (which has a ADDED ChangeType) may have a different ChangeType than the object in the list.
+    // For this reason, we look at the file name of the object at the insertion point and return it
+    // if it has the same name.
+    index = -1 * (index + 1);
+    if (index < patches.length && patches[index].getNewName().equals(fileName)) {
+      return patches[index];
+    }
+    return PatchListEntry.empty(fileName);
   }
 
   private int search(String fileName) {
-    PatchListEntry want = PatchListEntry.empty(fileName);
+    PatchListEntry want = PatchListEntry.empty(fileName, ChangeType.ADDED);
     return Arrays.binarySearch(patches, 0, patches.length, want, PATCH_CMP);
   }
 
diff --git a/java/com/google/gerrit/server/patch/PatchListCache.java b/java/com/google/gerrit/server/patch/PatchListCache.java
index 63cac0e..e60302a 100644
--- a/java/com/google/gerrit/server/patch/PatchListCache.java
+++ b/java/com/google/gerrit/server/patch/PatchListCache.java
@@ -21,10 +21,38 @@
 
 /** Provides a cached list of {@link PatchListEntry}. */
 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;
 
diff --git a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index b663b9d..a3e9a54 100644
--- a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.patch.filediff.PatchListLoader;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -37,7 +38,7 @@
 /** Provides a cached list of {@link PatchListEntry}. */
 @Singleton
 public class PatchListCacheImpl implements PatchListCache {
-  static final String FILE_NAME = "diff";
+  public static final String FILE_NAME = "diff";
   static final String INTRA_NAME = "diff_intraline";
   static final String DIFF_SUMMARY = "diff_summary";
 
diff --git a/java/com/google/gerrit/server/patch/PatchListEntry.java b/java/com/google/gerrit/server/patch/PatchListEntry.java
index c91355a..de292c3 100644
--- a/java/com/google/gerrit/server/patch/PatchListEntry.java
+++ b/java/com/google/gerrit/server/patch/PatchListEntry.java
@@ -49,8 +49,12 @@
   private static final byte[] EMPTY_HEADER = {};
 
   static PatchListEntry empty(String fileName) {
+    return empty(fileName, ChangeType.MODIFIED);
+  }
+
+  static PatchListEntry empty(String fileName, ChangeType changeType) {
     return new PatchListEntry(
-        ChangeType.MODIFIED,
+        changeType,
         PatchType.UNIFIED,
         null,
         fileName,
@@ -77,7 +81,7 @@
   // Note: When adding new fields, the serialVersionUID in PatchListKey must be
   // incremented so that entries from the cache are automatically invalidated.
 
-  PatchListEntry(
+  public PatchListEntry(
       FileHeader hdr, List<Edit> editList, Set<Edit> editsDueToRebase, long size, long sizeDelta) {
     changeType = toChangeType(hdr);
     patchType = toPatchType(hdr);
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index c6f7acf..5998bba 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server.patch;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -31,6 +32,8 @@
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.patch.DiffContentCalculator.DiffCalculatorResult;
 import com.google.gerrit.server.patch.DiffContentCalculator.TextSource;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.filediff.TaggedEdit;
 import com.google.inject.Inject;
 import eu.medsea.mimeutil.MimeType;
 import eu.medsea.mimeutil.MimeUtil2;
@@ -68,7 +71,8 @@
     intralineDiffCalculator = calculator;
   }
 
-  PatchScript toPatchScript(Repository git, PatchList list, PatchListEntry content)
+  /** Convert into {@link PatchScript} using the old diff cache output. */
+  PatchScript toPatchScriptOld(Repository git, PatchList list, PatchListEntry content)
       throws IOException {
 
     PatchFileChange change =
@@ -87,6 +91,32 @@
     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 {
+    PatchFileChange change =
+        new PatchFileChange(
+            content.edits().stream().map(TaggedEdit::jgitEdit).collect(toImmutableList()),
+            content.edits().stream()
+                .filter(TaggedEdit::dueToRebase)
+                .map(TaggedEdit::jgitEdit)
+                .collect(toImmutableSet()),
+            content.headerLines(),
+            FilePathAdapter.getOldPath(content.oldPath(), content.changeType()),
+            FilePathAdapter.getNewPath(content.oldPath(), content.newPath(), content.changeType()),
+            content.changeType(),
+            content.patchType().orElse(null));
+    SidesResolver sidesResolver = new SidesResolver(git, content.comparisonType());
+    ResolvedSides sides =
+        resolveSides(
+            git,
+            sidesResolver,
+            oldName(change),
+            newName(change),
+            content.oldCommitId(),
+            content.newCommitId());
+    return build(sides.a, sides.b, change);
+  }
+
   private ResolvedSides resolveSides(
       Repository git,
       SidesResolver sidesResolver,
@@ -352,16 +382,8 @@
         byte[] srcContent;
         if (reuse) {
           srcContent = other.srcContent;
-
-        } else if (mode.getObjectType() == Constants.OBJ_BLOB) {
-          srcContent = Text.asByteArray(db.open(id, Constants.OBJ_BLOB));
-
-        } else if (mode.getObjectType() == Constants.OBJ_COMMIT) {
-          String strContent = "Subproject commit " + ObjectId.toString(id);
-          srcContent = strContent.getBytes(UTF_8);
-
         } else {
-          srcContent = Text.NO_BYTES;
+          srcContent = SrcContentResolver.getSourceContent(db, id, mode);
         }
         String mimeType = MimeUtil2.UNKNOWN_MIME_TYPE.toString();
         DisplayMethod displayMethod = DisplayMethod.DIFF;
@@ -405,12 +427,7 @@
       if (mode == FileMode.MISSING) {
         displayMethod = DisplayMethod.NONE;
       }
-      PatchScript.FileMode fileMode = PatchScript.FileMode.FILE;
-      if (mode == FileMode.SYMLINK) {
-        fileMode = PatchScript.FileMode.SYMLINK;
-      } else if (mode == FileMode.GITLINK) {
-        fileMode = PatchScript.FileMode.GITLINK;
-      }
+      PatchScript.FileMode fileMode = PatchScript.FileMode.fromJgitFileMode(mode);
       return new PatchSide(
           treeId, path, id, mode, srcContent, src, mimeType, displayMethod, fileMode);
     }
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 02f46df..885459a 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -24,16 +24,25 @@
 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;
 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;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -41,15 +50,23 @@
 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;
 
@@ -64,34 +81,64 @@
         String fileName,
         @Assisted("patchSetA") PatchSet.Id patchSetA,
         @Assisted("patchSetB") PatchSet.Id patchSetB,
-        DiffPreferencesInfo diffPrefs);
+        DiffPreferencesInfo diffPrefs,
+        CurrentUser currentUser);
 
     PatchScriptFactory create(
         ChangeNotes notes,
         String fileName,
         int parentNum,
         PatchSet.Id patchSetB,
-        DiffPreferencesInfo diffPrefs);
+        DiffPreferencesInfo diffPrefs,
+        CurrentUser currentUser);
+  }
+
+  /** 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;
   private final int parentNum;
   private final PatchSet.Id psb;
   private final DiffPreferencesInfo diffPrefs;
+  private final CurrentUser currentUser;
+
   private final ChangeEditUtil editReader;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
+  private final DiffOperations diffOperations;
 
   private final Change.Id changeId;
 
   private ChangeNotes notes;
 
+  private final boolean runNewDiffCache;
+
   @AssistedInject
   PatchScriptFactory(
       GitRepositoryManager grm,
@@ -101,11 +148,16 @@
       ChangeEditUtil editReader,
       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,
       @Assisted("patchSetB") PatchSet.Id patchSetB,
-      @Assisted DiffPreferencesInfo diffPrefs) {
+      @Assisted DiffPreferencesInfo diffPrefs,
+      @Assisted CurrentUser currentUser) {
     this.repoManager = grm;
     this.psUtil = psUtil;
     this.builderFactory = builderFactory;
@@ -114,12 +166,18 @@
     this.editReader = editReader;
     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.psb = patchSetB;
     this.diffPrefs = diffPrefs;
+    this.currentUser = currentUser;
+
+    this.runNewDiffCache = cfg.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);
 
     changeId = patchSetB.changeId();
   }
@@ -133,11 +191,16 @@
       ChangeEditUtil editReader,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
+      DiffOperations diffOperations,
+      Metrics metrics,
+      @DiffExecutor ExecutorService executor,
+      @GerritServerConfig Config cfg,
       @Assisted ChangeNotes notes,
       @Assisted String fileName,
       @Assisted int parentNum,
       @Assisted PatchSet.Id patchSetB,
-      @Assisted DiffPreferencesInfo diffPrefs) {
+      @Assisted DiffPreferencesInfo diffPrefs,
+      @Assisted CurrentUser currentUser) {
     this.repoManager = grm;
     this.psUtil = psUtil;
     this.builderFactory = builderFactory;
@@ -146,12 +209,18 @@
     this.editReader = editReader;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
+    this.diffOperations = diffOperations;
+    this.metrics = metrics;
+    this.executor = executor;
 
     this.fileName = fileName;
     this.psa = null;
     this.parentNum = parentNum;
     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");
@@ -163,7 +232,7 @@
           PermissionBackendException {
 
     try {
-      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
+      permissionBackend.user(currentUser).change(notes).check(ChangePermission.READ);
     } catch (AuthException e) {
       throw new NoSuchChangeException(changeId, e);
     }
@@ -190,14 +259,19 @@
           }
           bId = edit.get().getEditCommit();
         }
-
-        final PatchList list = listFor(keyFor(aId, bId, diffPrefs.ignoreWhitespace));
-        final PatchScriptBuilder b = newBuilder();
-        final PatchListEntry content = list.get(fileName);
-
-        return b.toPatchScript(git, list, content);
+        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);
+      } catch (DiffNotAvailableException e) {
+        throw new StorageException(e);
       } catch (IOException e) {
         logger.atSevere().withCause(e).log("File content unavailable");
         throw new NoSuchChangeException(changeId, e);
@@ -213,6 +287,111 @@
     }
   }
 
+  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)
+      throws IOException, DiffNotAvailableException {
+    FileDiffOutput fileDiffOutput =
+        aId == null
+            ? diffOperations.getModifiedFileAgainstParent(
+                notes.getProjectName(),
+                bId,
+                parentNum == -1 ? null : parentNum + 1,
+                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;
+  }
+
   private Optional<ObjectId> getAId() {
     if (psa == null) {
       return Optional.empty();
diff --git a/java/com/google/gerrit/server/patch/SrcContentResolver.java b/java/com/google/gerrit/server/patch/SrcContentResolver.java
new file mode 100644
index 0000000..9cd11d2
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/SrcContentResolver.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.patch;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.IOException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+/** Resolver of the source content of a specific file */
+public class SrcContentResolver {
+
+  private SrcContentResolver() {}
+
+  /**
+   * Return the source content of a specific file.
+   *
+   * @param repo Git repository.
+   * @param id Git Object ID of the file blob.
+   * @param fileMode File mode of the underlying file as recognized by Git.
+   * @return byte[] source content of the underlying file if the {@code id} is of type blob, or a
+   *     textual representation of the file if it is a git submodule.
+   * @throws IOException the object ID does not exist in the repository or cannot be accessed.
+   */
+  public static byte[] getSourceContent(Repository repo, ObjectId id, FileMode fileMode)
+      throws IOException {
+    if (fileMode.getObjectType() == Constants.OBJ_BLOB) {
+      return Text.asByteArray(repo.open(id, Constants.OBJ_BLOB));
+    }
+    if (fileMode.getObjectType() == Constants.OBJ_COMMIT) {
+      String strContent = "Subproject commit " + ObjectId.toString(id);
+      return strContent.getBytes(UTF_8);
+    }
+    return Text.NO_BYTES;
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
new file mode 100644
index 0000000..18d532b
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -0,0 +1,278 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.gerrit.common.data.PatchScript;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.prettify.common.SparseFileContent;
+import com.google.gerrit.prettify.common.SparseFileContent.Accessor;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.git.validators.CommentCumulativeSizeValidator;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * This class is used on submit to compute the diff between the latest approved patch-set, and the
+ * current submitted patch-set.
+ *
+ * <p>Latest approved patch-set is defined by the latest patch-set which has Code-Review label voted
+ * with the maximum possible value.
+ *
+ * <p>If the latest approved patch-set is the same as the submitted patch-set, the diff will be
+ * empty.
+ *
+ * <p>We exclude the magic files from the returned diff to make it shorter and more concise.
+ */
+public class SubmitWithStickyApprovalDiff {
+  private final ProjectCache projectCache;
+  private final PatchScriptFactory.Factory patchScriptFactoryFactory;
+  private final PatchListCache patchListCache;
+  private final int maxCumulativeSize;
+
+  @Inject
+  SubmitWithStickyApprovalDiff(
+      ProjectCache projectCache,
+      PatchScriptFactory.Factory patchScriptFactoryFactory,
+      PatchListCache patchListCache,
+      @GerritServerConfig Config serverConfig) {
+    this.projectCache = projectCache;
+    this.patchScriptFactoryFactory = patchScriptFactoryFactory;
+    this.patchListCache = patchListCache;
+    maxCumulativeSize =
+        serverConfig.getInt(
+            "change",
+            "cumulativeCommentSizeLimit",
+            CommentCumulativeSizeValidator.DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT);
+  }
+
+  public String apply(ChangeNotes notes, CurrentUser currentUser)
+      throws AuthException, IOException, PermissionBackendException,
+          InvalidChangeOperationException {
+    // In some submit strategies, the current patch-set doesn't exist yet as it's being created
+    // during the submit. Hence, we assign the current patch-set to be the last existing patch-set.
+    PatchSet currentPatchset =
+        notes.getPatchSets().values().stream()
+            .max((p1, p2) -> p1.id().get() - p2.id().get())
+            .orElseThrow(
+                () ->
+                    new IllegalStateException(
+                        String.format(
+                            "change %s can't load any patchset", notes.getChangeId().toString())));
+
+    PatchSet.Id latestApprovedPatchsetId = getLatestApprovedPatchsetId(notes);
+    if (latestApprovedPatchsetId.get() == currentPatchset.id().get()) {
+      // If the latest approved patchset is the current patchset, no need to return anything.
+      return "";
+    }
+    StringBuilder diff =
+        new StringBuilder(
+            String.format(
+                "\n\n%d is the latest approved patch-set.\n", latestApprovedPatchsetId.get()));
+    PatchList patchList =
+        getPatchList(
+            notes.getProjectName(),
+            currentPatchset,
+            notes.getPatchSets().get(latestApprovedPatchsetId));
+
+    // To make the message a bit more concise, we skip the magic files.
+    List<PatchListEntry> patchListEntryList =
+        patchList.getPatches().stream()
+            .filter(p -> !Patch.isMagic(p.getNewName()))
+            .collect(Collectors.toList());
+
+    if (patchListEntryList.isEmpty()) {
+      diff.append(
+          "No files were changed between the latest approved patch-set and the submitted one.\n");
+      return diff.toString();
+    }
+
+    diff.append("The change was submitted with unreviewed changes in the following files:\n\n");
+
+    for (PatchListEntry patchListEntry : patchListEntryList) {
+      diff.append(
+          getDiffForFile(
+              notes, currentPatchset.id(), latestApprovedPatchsetId, patchListEntry, currentUser));
+    }
+    if (diff.length() > maxCumulativeSize) {
+      // The diff length is not counted as part of the limit (for technical reasons, since we'd
+      // have to call CommentCumulativeSizeValidator), but it's best not to post an extra large
+      // change message here.
+      return String.format(
+          "\n\n%d is the latest approved patch-set.\nThe change was submitted "
+              + "with many unreviewed changes (the diff is too large to show). Please review the "
+              + "diff.",
+          latestApprovedPatchsetId.get());
+    }
+    return diff.toString();
+  }
+
+  private String getDiffForFile(
+      ChangeNotes notes,
+      PatchSet.Id currentPatchsetId,
+      PatchSet.Id latestApprovedPatchsetId,
+      PatchListEntry patchListEntry,
+      CurrentUser currentUser)
+      throws AuthException, InvalidChangeOperationException, IOException,
+          PermissionBackendException {
+    StringBuilder diff =
+        new StringBuilder(
+            String.format(
+                "The name of the file: %s\nInsertions: %d, Deletions: %d.\n\n",
+                patchListEntry.getNewName(),
+                patchListEntry.getInsertions(),
+                patchListEntry.getDeletions()));
+    DiffPreferencesInfo diffPreferencesInfo = createDefaultDiffPreferencesInfo();
+    PatchScriptFactory patchScriptFactory =
+        patchScriptFactoryFactory.create(
+            notes,
+            patchListEntry.getNewName(),
+            latestApprovedPatchsetId,
+            currentPatchsetId,
+            diffPreferencesInfo,
+            currentUser);
+    PatchScript patchScript = null;
+    try {
+      patchScript = patchScriptFactory.call();
+    } catch (LargeObjectException exception) {
+      diff.append("The file content is too large for showing the full diff. \n\n");
+      return diff.toString();
+    }
+    if (patchScript.getChangeType() == ChangeType.RENAMED) {
+      diff.append(
+          String.format(
+              "The file %s was renamed to %s\n",
+              patchListEntry.getOldName(), patchListEntry.getNewName()));
+    }
+    SparseFileContent.Accessor fileA = patchScript.getA().createAccessor();
+    SparseFileContent.Accessor fileB = patchScript.getB().createAccessor();
+    boolean editsExist = false;
+    if (patchScript.getEdits().stream().anyMatch(e -> e.getType() != Edit.Type.EMPTY)) {
+      diff.append("```\n");
+      editsExist = true;
+    }
+    for (Edit edit : patchScript.getEdits()) {
+      diff.append(getDiffForEdit(fileA, fileB, edit));
+    }
+    if (editsExist) {
+      diff.append("```\n");
+    }
+    return diff.toString();
+  }
+
+  private String getDiffForEdit(Accessor fileA, Accessor fileB, Edit edit) {
+    StringBuilder diff = new StringBuilder();
+    Edit.Type type = edit.getType();
+    switch (type) {
+      case INSERT:
+        diff.append(String.format("@@ +%d:%d @@\n", edit.getBeginB(), edit.getEndB()));
+        diff.append(getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+'));
+        diff.append("\n");
+        break;
+      case DELETE:
+        diff.append(String.format("@@ -%d:%d @@\n", edit.getBeginA(), edit.getEndA()));
+        diff.append(getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-'));
+        diff.append("\n");
+        break;
+      case REPLACE:
+        diff.append(
+            String.format(
+                "@@ -%d:%d, +%d:%d @@\n",
+                edit.getBeginA(), edit.getEndA(), edit.getBeginB(), edit.getEndB()));
+        diff.append(getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-'));
+        diff.append(getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+'));
+        diff.append("\n");
+        break;
+      case EMPTY:
+        // do nothing since there is no change here.
+    }
+    return diff.toString();
+  }
+
+  private String getModifiedLines(Accessor file, int begin, int end, char modificationType) {
+    StringBuilder diff = new StringBuilder();
+    for (int i = begin; i < end; i++) {
+      diff.append(String.format("%c  %s\n", modificationType, file.get(i)));
+    }
+    return diff.toString();
+  }
+
+  private DiffPreferencesInfo createDefaultDiffPreferencesInfo() {
+    DiffPreferencesInfo diffPreferencesInfo = new DiffPreferencesInfo();
+    diffPreferencesInfo.ignoreWhitespace = Whitespace.IGNORE_NONE;
+    diffPreferencesInfo.intralineDifference = true;
+    return diffPreferencesInfo;
+  }
+
+  private PatchSet.Id getLatestApprovedPatchsetId(ChangeNotes notes) {
+    ProjectState projectState =
+        projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
+    PatchSet.Id maxPatchSetId = PatchSet.id(notes.getChangeId(), 1);
+    for (PatchSetApproval patchSetApproval : notes.getApprovals().values()) {
+      if (!patchSetApproval.label().equals(LabelId.CODE_REVIEW)) {
+        continue;
+      }
+      if (!projectState
+          .getLabelTypes(notes)
+          .byLabel(patchSetApproval.labelId())
+          .isMaxPositive(patchSetApproval)) {
+        continue;
+      }
+      if (patchSetApproval.patchSetId().get() > maxPatchSetId.get()) {
+        maxPatchSetId = patchSetApproval.patchSetId();
+      }
+    }
+    return maxPatchSetId;
+  }
+
+  /**
+   * Gets the {@link PatchList} between the two latest patch-sets. Can be used to compute difference
+   * in files between those two patch-sets .
+   */
+  private PatchList getPatchList(Project.NameKey project, PatchSet ps, PatchSet priorPatchSet) {
+    PatchListKey key =
+        PatchListKey.againstCommit(
+            priorPatchSet.commitId(), ps.commitId(), DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+    try {
+      return patchListCache.get(key, project);
+    } catch (PatchListNotAvailableException ex) {
+      throw new StorageException(
+          "failed to compute difference in files, so won't post diff messsage on submit although "
+              + "the latest approved patch-set was not the same as the submitted patch-set.",
+          ex);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
new file mode 100644
index 0000000..bcae238
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
@@ -0,0 +1,45 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.diff;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+
+/**
+ * A cache for the list of Git modified files between 2 commits (patchsets) with extra Gerrit logic.
+ *
+ * <p>The loader uses the underlying {@link GitModifiedFilesCacheImpl} to retrieve the git modified
+ * files.
+ *
+ * <p>If the {@link ModifiedFilesCacheKey#aCommit()} is equal to {@link
+ * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the diff will be evaluated against the empty tree,
+ * and the result will be exactly the same as the caller can get from {@link
+ * GitModifiedFilesCache#get(GitModifiedFilesCacheKey)}
+ */
+public interface ModifiedFilesCache {
+
+  /**
+   * @param key used to identify two git commits and contains other attributes to control the diff
+   *     calculation.
+   * @return the list of {@link ModifiedFile}s between the 2 git commits identified by the key.
+   * @throws DiffNotAvailableException the supplied commits IDs of the key do no exist, are not IDs
+   *     of a commit, or an exception occurred while reading a pack file.
+   */
+  ImmutableList<ModifiedFile> get(ModifiedFilesCacheKey key) throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
new file mode 100644
index 0000000..6023c0e
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
@@ -0,0 +1,206 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.diff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffUtil;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A cache for the list of Git modified files between 2 commits (patchsets) with extra Gerrit logic.
+ *
+ * <p>The loader of this cache wraps a {@link GitModifiedFilesCache} to retrieve the git modified
+ * files.
+ *
+ * <p>If the {@link ModifiedFilesCacheKey#aCommit()} is equal to {@link
+ * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the diff will be evaluated against the empty tree,
+ * and the result will be exactly the same as the caller can get from {@link
+ * GitModifiedFilesCache#get(GitModifiedFilesCacheKey)}
+ */
+public class ModifiedFilesCacheImpl implements ModifiedFilesCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String MODIFIED_FILES = "modified_files";
+
+  private final LoadingCache<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(ModifiedFilesCache.class).to(ModifiedFilesCacheImpl.class);
+
+        // The documentation has some defaults and recommendations for setting the cache
+        // attributes:
+        // https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#cache.
+        // The cache is using the default disk limit as per section cache.<name>.diskLimit
+        // in the cache documentation link.
+        persist(
+                ModifiedFilesCacheImpl.MODIFIED_FILES,
+                ModifiedFilesCacheKey.class,
+                new TypeLiteral<ImmutableList<ModifiedFile>>() {})
+            .keySerializer(ModifiedFilesCacheKey.Serializer.INSTANCE)
+            .valueSerializer(GitModifiedFilesCacheImpl.ValueSerializer.INSTANCE)
+            .maximumWeight(10 << 20)
+            .weigher(ModifiedFilesWeigher.class)
+            .version(1)
+            .loader(ModifiedFilesLoader.class);
+      }
+    };
+  }
+
+  @Inject
+  public ModifiedFilesCacheImpl(
+      @Named(ModifiedFilesCacheImpl.MODIFIED_FILES)
+          LoadingCache<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public ImmutableList<ModifiedFile> get(ModifiedFilesCacheKey key)
+      throws DiffNotAvailableException {
+    try {
+      return cache.get(key);
+    } catch (Exception e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  static class ModifiedFilesLoader
+      extends CacheLoader<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+    private final GitModifiedFilesCache gitCache;
+    private final GitRepositoryManager repoManager;
+
+    @Inject
+    ModifiedFilesLoader(GitModifiedFilesCache gitCache, GitRepositoryManager repoManager) {
+      this.gitCache = gitCache;
+      this.repoManager = repoManager;
+    }
+
+    @Override
+    public ImmutableList<ModifiedFile> load(ModifiedFilesCacheKey key)
+        throws IOException, DiffNotAvailableException {
+      try (Repository repo = repoManager.openRepository(key.project());
+          RevWalk rw = new RevWalk(repo.newObjectReader())) {
+        return loadModifiedFiles(key, rw);
+      }
+    }
+
+    private ImmutableList<ModifiedFile> loadModifiedFiles(ModifiedFilesCacheKey key, RevWalk rw)
+        throws IOException, DiffNotAvailableException {
+      ObjectId aTree =
+          key.aCommit().equals(EMPTY_TREE_ID)
+              ? key.aCommit()
+              : DiffUtil.getTreeId(rw, key.aCommit());
+      ObjectId bTree = DiffUtil.getTreeId(rw, key.bCommit());
+      GitModifiedFilesCacheKey gitKey =
+          GitModifiedFilesCacheKey.builder()
+              .project(key.project())
+              .aTree(aTree)
+              .bTree(bTree)
+              .renameScore(key.renameScore())
+              .build();
+      List<ModifiedFile> modifiedFiles = gitCache.get(gitKey);
+      if (key.aCommit().equals(EMPTY_TREE_ID)) {
+        return ImmutableList.copyOf(modifiedFiles);
+      }
+      RevCommit revCommitA = DiffUtil.getRevCommit(rw, key.aCommit());
+      RevCommit revCommitB = DiffUtil.getRevCommit(rw, key.bCommit());
+      if (DiffUtil.areRelated(revCommitA, revCommitB)) {
+        return ImmutableList.copyOf(modifiedFiles);
+      }
+      Set<String> touchedFiles =
+          getTouchedFilesWithParents(
+              key, revCommitA.getParent(0).getId(), revCommitB.getParent(0).getId(), rw);
+      return modifiedFiles.stream()
+          .filter(f -> isTouched(touchedFiles, f))
+          .collect(toImmutableList());
+    }
+
+    /**
+     * Returns the paths of files that were modified between the old and new commits versus their
+     * parents (i.e. old commit vs. its parent, and new commit vs. its parent).
+     *
+     * @param key the {@link ModifiedFilesCacheKey} representing the commits we are diffing
+     * @param rw a {@link RevWalk} for the repository
+     * @return The list of modified files between the old/new commits and their parents
+     */
+    private Set<String> getTouchedFilesWithParents(
+        ModifiedFilesCacheKey key, ObjectId parentOfA, ObjectId parentOfB, RevWalk rw)
+        throws IOException {
+      try {
+        // TODO(ghareeb): as an enhancement: the 3 calls of the underlying git cache can be combined
+        GitModifiedFilesCacheKey oldVsBaseKey =
+            GitModifiedFilesCacheKey.create(
+                key.project(), parentOfA, key.aCommit(), key.renameScore(), rw);
+        List<ModifiedFile> oldVsBase = gitCache.get(oldVsBaseKey);
+
+        GitModifiedFilesCacheKey newVsBaseKey =
+            GitModifiedFilesCacheKey.create(
+                key.project(), parentOfB, key.bCommit(), key.renameScore(), rw);
+        List<ModifiedFile> newVsBase = gitCache.get(newVsBaseKey);
+
+        return Sets.union(getOldAndNewPaths(oldVsBase), getOldAndNewPaths(newVsBase));
+      } catch (DiffNotAvailableException e) {
+        logger.atWarning().log(
+            "Failed to retrieve the touched files' commits (%s, %s) and parents (%s, %s): %s",
+            key.aCommit(), key.bCommit(), parentOfA, parentOfB, e.getMessage());
+        return ImmutableSet.of();
+      }
+    }
+
+    private ImmutableSet<String> getOldAndNewPaths(List<ModifiedFile> files) {
+      return files.stream()
+          .flatMap(
+              file -> Stream.concat(Streams.stream(file.oldPath()), Streams.stream(file.newPath())))
+          .collect(ImmutableSet.toImmutableSet());
+    }
+
+    private static boolean isTouched(Set<String> touchedFilePaths, ModifiedFile modifiedFile) {
+      String oldFilePath = modifiedFile.oldPath().orElse(null);
+      String newFilePath = modifiedFile.newPath().orElse(null);
+      // One of the above file paths could be /dev/null but we need not explicitly check for this
+      // value as the set of file paths shouldn't contain it.
+      return touchedFilePaths.contains(oldFilePath) || touchedFilePaths.contains(newFilePath);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java
new file mode 100644
index 0000000..2ac3f5e
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch.diff;
+
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.ModifiedFilesKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Cache key for the {@link com.google.gerrit.server.patch.diff.ModifiedFilesCache} */
+@AutoValue
+public abstract class ModifiedFilesCacheKey {
+
+  /** A specific git project / repository. */
+  public abstract Project.NameKey project();
+
+  /** @return the old commit ID used in the git tree diff */
+  public abstract ObjectId aCommit();
+
+  /** @return the new commit ID used in the git tree diff */
+  public abstract ObjectId bCommit();
+
+  /**
+   * Percentage score used to identify a file as a "rename". A special value of -1 means that the
+   * computation will ignore renames and rename detection will be disabled.
+   */
+  public abstract int renameScore();
+
+  public boolean renameDetectionEnabled() {
+    return renameScore() != -1;
+  }
+
+  /** Returns the size of the object in bytes */
+  public int weight() {
+    return stringSize(project().get()) // project
+        + 20 * 2 // aCommit and bCommit
+        + 4; // renameScore
+  }
+
+  public static ModifiedFilesCacheKey.Builder builder() {
+    return new AutoValue_ModifiedFilesCacheKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract ModifiedFilesCacheKey.Builder project(NameKey value);
+
+    public abstract ModifiedFilesCacheKey.Builder aCommit(ObjectId value);
+
+    public abstract ModifiedFilesCacheKey.Builder bCommit(ObjectId value);
+
+    public ModifiedFilesCacheKey.Builder disableRenameDetection() {
+      renameScore(-1);
+      return this;
+    }
+
+    public abstract ModifiedFilesCacheKey.Builder renameScore(int value);
+
+    public abstract ModifiedFilesCacheKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<ModifiedFilesCacheKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(ModifiedFilesCacheKey key) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return Protos.toByteArray(
+          ModifiedFilesKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setACommit(idConverter.toByteString(key.aCommit()))
+              .setBCommit(idConverter.toByteString(key.bCommit()))
+              .setRenameScore(key.renameScore())
+              .build());
+    }
+
+    @Override
+    public ModifiedFilesCacheKey deserialize(byte[] in) {
+      ModifiedFilesKeyProto proto = Protos.parseUnchecked(ModifiedFilesKeyProto.parser(), in);
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return ModifiedFilesCacheKey.builder()
+          .project(NameKey.parse(proto.getProject()))
+          .aCommit(idConverter.fromByteString(proto.getACommit()))
+          .bCommit(idConverter.fromByteString(proto.getBCommit()))
+          .renameScore(proto.getRenameScore())
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
new file mode 100644
index 0000000..512da6f
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
@@ -0,0 +1,31 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.diff;
+
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+
+public class ModifiedFilesWeigher
+    implements Weigher<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+  @Override
+  public int weigh(ModifiedFilesCacheKey key, ImmutableList<ModifiedFile> modifiedFiles) {
+    int weight = key.weight();
+    for (ModifiedFile modifiedFile : modifiedFiles) {
+      weight += modifiedFile.weight();
+    }
+    return weight;
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java b/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java
new file mode 100644
index 0000000..12decc3
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java
@@ -0,0 +1,218 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffUtil;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiff;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCache;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheKey;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A helper class that computes the four {@link GitFileDiff}s for a list of {@link
+ * FileDiffCacheKey}s:
+ *
+ * <ul>
+ *   <li>old commit vs. new commit
+ *   <li>old parent vs. old commit
+ *   <li>new parent vs. new commit
+ *   <li>old parent vs. new parent
+ * </ul>
+ *
+ * The four {@link GitFileDiff} are stored in the entity class {@link AllFileGitDiffs}. We use these
+ * diffs to identify the edits due to rebase using the {@link EditTransformer} class.
+ */
+class AllDiffsEvaluator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final RevWalk rw;
+  private final GitFileDiffCache gitCache;
+
+  interface Factory {
+    AllDiffsEvaluator create(RevWalk rw);
+  }
+
+  @Inject
+  private AllDiffsEvaluator(GitFileDiffCache gitCache, @Assisted RevWalk rw) {
+    this.gitCache = gitCache;
+    this.rw = rw;
+  }
+
+  Map<AugmentedFileDiffCacheKey, AllFileGitDiffs> execute(
+      List<AugmentedFileDiffCacheKey> augmentedKeys) throws DiffNotAvailableException {
+    ImmutableMap.Builder<AugmentedFileDiffCacheKey, AllFileGitDiffs> keyToAllDiffs =
+        ImmutableMap.builderWithExpectedSize(augmentedKeys.size());
+
+    List<AugmentedFileDiffCacheKey> keysWithRebaseEdits =
+        augmentedKeys.stream().filter(k -> !k.ignoreRebase()).collect(Collectors.toList());
+
+    // TODO(ghareeb): as an enhancement, you can batch these calls as follows.
+    // First batch: "old commit vs. new commit" and "new parent vs. new commit"
+    // Second batch: "old parent vs. old commit" and "old parent vs. new parent"
+
+    Map<FileDiffCacheKey, GitDiffEntity> mainDiffs =
+        computeGitFileDiffs(
+            createGitKeys(
+                augmentedKeys,
+                k -> k.key().oldCommit(),
+                k -> k.key().newCommit(),
+                k -> k.key().newFilePath()));
+
+    Map<FileDiffCacheKey, GitDiffEntity> oldVsParentDiffs =
+        computeGitFileDiffs(
+            createGitKeys(
+                keysWithRebaseEdits,
+                k -> k.oldParentId().get(), // oldParent is set for keysWithRebaseEdits
+                k -> k.key().oldCommit(),
+                k -> mainDiffs.get(k.key()).gitDiff().oldPath().orElse(null)));
+
+    Map<FileDiffCacheKey, GitDiffEntity> newVsParentDiffs =
+        computeGitFileDiffs(
+            createGitKeys(
+                keysWithRebaseEdits,
+                k -> k.newParentId().get(), // newParent is set for keysWithRebaseEdits
+                k -> k.key().newCommit(),
+                k -> k.key().newFilePath()));
+
+    Map<FileDiffCacheKey, GitDiffEntity> parentsDiffs =
+        computeGitFileDiffs(
+            createGitKeys(
+                keysWithRebaseEdits,
+                k -> k.oldParentId().get(),
+                k -> k.newParentId().get(),
+                k -> {
+                  GitFileDiff newVsParDiff = newVsParentDiffs.get(k.key()).gitDiff();
+                  // TODO(ghareeb): Follow up on replacing key.newFilePath as a fallback.
+                  // If the file was added between newParent and newCommit, we actually wouldn't
+                  // need to have to determine the oldParent vs. newParent diff as nothing in
+                  // that file could be an edit due to rebase anymore. Only if the returned diff
+                  // is empty, the oldParent vs. newParent diff becomes relevant again (e.g. to
+                  // identify a file deletion which was due to rebase. Check if the structure
+                  // can be improved to make this clearer. Can we maybe even skip the diff in
+                  // the first situation described?
+                  return newVsParDiff.oldPath().orElse(k.key().newFilePath());
+                }));
+
+    for (AugmentedFileDiffCacheKey augmentedKey : augmentedKeys) {
+      FileDiffCacheKey key = augmentedKey.key();
+      AllFileGitDiffs.Builder builder =
+          AllFileGitDiffs.builder().augmentedKey(augmentedKey).mainDiff(mainDiffs.get(key));
+
+      if (augmentedKey.ignoreRebase()) {
+        keyToAllDiffs.put(augmentedKey, builder.build());
+        continue;
+      }
+
+      if (oldVsParentDiffs.containsKey(key) && !oldVsParentDiffs.get(key).gitDiff().isEmpty()) {
+        builder.oldVsParentDiff(Optional.of(oldVsParentDiffs.get(key)));
+      }
+
+      if (newVsParentDiffs.containsKey(key) && !newVsParentDiffs.get(key).gitDiff().isEmpty()) {
+        builder.newVsParentDiff(Optional.of(newVsParentDiffs.get(key)));
+      }
+
+      if (parentsDiffs.containsKey(key) && !parentsDiffs.get(key).gitDiff().isEmpty()) {
+        builder.parentVsParentDiff(Optional.of(parentsDiffs.get(key)));
+      }
+
+      keyToAllDiffs.put(augmentedKey, builder.build());
+    }
+    return keyToAllDiffs.build();
+  }
+
+  /**
+   * Computes the git diff for the git keys of the input map {@code keys} parameter. The computation
+   * uses the underlying {@link GitFileDiffCache}.
+   */
+  private Map<FileDiffCacheKey, GitDiffEntity> computeGitFileDiffs(
+      Map<FileDiffCacheKey, GitFileDiffCacheKey> keys) throws DiffNotAvailableException {
+    ImmutableMap.Builder<FileDiffCacheKey, GitDiffEntity> result =
+        ImmutableMap.builderWithExpectedSize(keys.size());
+    ImmutableMap<GitFileDiffCacheKey, GitFileDiff> gitDiffs = gitCache.getAll(keys.values());
+    for (FileDiffCacheKey key : keys.keySet()) {
+      GitFileDiffCacheKey gitKey = keys.get(key);
+      GitFileDiff gitFileDiff = gitDiffs.get(gitKey);
+      result.put(key, GitDiffEntity.create(gitKey, gitFileDiff));
+    }
+    return result.build();
+  }
+
+  /**
+   * Convert a list of {@link AugmentedFileDiffCacheKey} to their corresponding {@link
+   * GitFileDiffCacheKey} which can be used to call the underlying {@link GitFileDiffCache}.
+   *
+   * @param keys a list of input {@link AugmentedFileDiffCacheKey}s.
+   * @param aCommitFn a function to compute the aCommit that will be used in the git diff.
+   * @param bCommitFn a function to compute the bCommit that will be used in the git diff.
+   * @param newPathFn a function to compute the new path of the git key.
+   * @return a map of the input {@link FileDiffCacheKey} to the {@link GitFileDiffCacheKey}.
+   */
+  private Map<FileDiffCacheKey, GitFileDiffCacheKey> createGitKeys(
+      List<AugmentedFileDiffCacheKey> keys,
+      Function<AugmentedFileDiffCacheKey, ObjectId> aCommitFn,
+      Function<AugmentedFileDiffCacheKey, ObjectId> bCommitFn,
+      Function<AugmentedFileDiffCacheKey, String> newPathFn) {
+    Map<FileDiffCacheKey, GitFileDiffCacheKey> result = new HashMap<>();
+    for (AugmentedFileDiffCacheKey key : keys) {
+      try {
+        String path = newPathFn.apply(key);
+        if (path != null) {
+          result.put(
+              key.key(),
+              createGitKey(key.key(), aCommitFn.apply(key), bCommitFn.apply(key), path, rw));
+        }
+      } catch (IOException e) {
+        // TODO(ghareeb): This implies that the output keys may not have the same size as the input.
+        // Check the caller's code path about the correctness of the computation in this case. If
+        // errors are rare, it may be better to throw an exception and fail the whole computation.
+        logger.atWarning().log("Failed to compute the git key for key %s: %s", key, e.getMessage());
+      }
+    }
+    return result;
+  }
+
+  /** Returns the {@link GitFileDiffCacheKey} for the {@code key} input parameter. */
+  private GitFileDiffCacheKey createGitKey(
+      FileDiffCacheKey key, ObjectId aCommit, ObjectId bCommit, String pathNew, RevWalk rw)
+      throws IOException {
+    ObjectId oldTreeId =
+        aCommit.equals(EMPTY_TREE_ID) ? EMPTY_TREE_ID : DiffUtil.getTreeId(rw, aCommit);
+    ObjectId newTreeId = DiffUtil.getTreeId(rw, bCommit);
+    return GitFileDiffCacheKey.builder()
+        .project(key.project())
+        .oldTree(oldTreeId)
+        .newTree(newTreeId)
+        .newFilePath(pathNew == null ? key.newFilePath() : pathNew)
+        .renameScore(key.renameScore())
+        .diffAlgorithm(key.diffAlgorithm())
+        .whitespace(key.whitespace())
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/AllFileGitDiffs.java b/java/com/google/gerrit/server/patch/filediff/AllFileGitDiffs.java
new file mode 100644
index 0000000..3b1886f
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/AllFileGitDiffs.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import com.google.auto.value.AutoValue;
+import java.util.Optional;
+
+/**
+ * An entity containing the four git diffs for a {@link FileDiffCacheKey}:
+ *
+ * <ol>
+ *   <li>The old vs. new commit
+ *   <li>The old commit vs. the old parent
+ *   <li>The new commit vs. the new parent
+ *   <li>The old parent vs. the new parent
+ * </ol>
+ */
+@AutoValue
+abstract class AllFileGitDiffs {
+  abstract AugmentedFileDiffCacheKey augmentedKey();
+
+  abstract GitDiffEntity mainDiff();
+
+  abstract Optional<GitDiffEntity> oldVsParentDiff();
+
+  abstract Optional<GitDiffEntity> newVsParentDiff();
+
+  abstract Optional<GitDiffEntity> parentVsParentDiff();
+
+  static AllFileGitDiffs.Builder builder() {
+    return new AutoValue_AllFileGitDiffs.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder augmentedKey(AugmentedFileDiffCacheKey value);
+
+    public abstract Builder mainDiff(GitDiffEntity value);
+
+    public abstract Builder oldVsParentDiff(Optional<GitDiffEntity> value);
+
+    public abstract Builder newVsParentDiff(Optional<GitDiffEntity> value);
+
+    public abstract Builder parentVsParentDiff(Optional<GitDiffEntity> value);
+
+    public abstract AllFileGitDiffs build();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/AugmentedFileDiffCacheKey.java b/java/com/google/gerrit/server/patch/filediff/AugmentedFileDiffCacheKey.java
new file mode 100644
index 0000000..8e40452
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/AugmentedFileDiffCacheKey.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import com.google.auto.value.AutoValue;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * A wrapper entity to the {@link FileDiffCacheKey} that also includes the old parent commit ID, the
+ * new parent commit ID and if we should ignore computing the rebase edits for that key.
+ */
+@AutoValue
+abstract class AugmentedFileDiffCacheKey {
+  abstract FileDiffCacheKey key();
+
+  abstract boolean ignoreRebase();
+
+  abstract Optional<ObjectId> oldParentId();
+
+  abstract Optional<ObjectId> newParentId();
+
+  static Builder builder() {
+    return new AutoValue_AugmentedFileDiffCacheKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder oldParentId(Optional<ObjectId> value);
+
+    public abstract Builder newParentId(Optional<ObjectId> value);
+
+    public abstract Builder ignoreRebase(boolean value);
+
+    public abstract Builder key(FileDiffCacheKey value);
+
+    public abstract AugmentedFileDiffCacheKey build();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/Edit.java b/java/com/google/gerrit/server/patch/filediff/Edit.java
new file mode 100644
index 0000000..4a698a4
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/Edit.java
@@ -0,0 +1,54 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * A modified region between 2 versions of the same content. This is the Gerrit entity class
+ * corresponding to {@link org.eclipse.jgit.diff.Edit} and is needed to ensure immutability when
+ * included as fields of the diff persisted caches.
+ */
+@AutoValue
+public abstract class Edit {
+  public static Edit create(int beginA, int endA, int beginB, int endB) {
+    return new AutoValue_Edit(beginA, endA, beginB, endB);
+  }
+
+  public static Edit fromJGitEdit(org.eclipse.jgit.diff.Edit jgitEdit) {
+    return create(
+        jgitEdit.getBeginA(), jgitEdit.getEndA(), jgitEdit.getBeginB(), jgitEdit.getEndB());
+  }
+
+  public static org.eclipse.jgit.diff.Edit toJGitEdit(Edit e) {
+    return new org.eclipse.jgit.diff.Edit(e.beginA(), e.endA(), e.beginB(), e.endB());
+  }
+
+  public org.eclipse.jgit.diff.Edit asJGitEdit() {
+    return new org.eclipse.jgit.diff.Edit(beginA(), endA(), beginB(), endB());
+  }
+
+  /** Start of a region in sequence A. */
+  public abstract int beginA();
+
+  /** End of a region in sequence A. */
+  public abstract int endA();
+
+  /** Start of a region in sequence B. */
+  public abstract int beginB();
+
+  /** End of a region in sequence B. */
+  public abstract int endB();
+}
diff --git a/java/com/google/gerrit/server/patch/EditTransformer.java b/java/com/google/gerrit/server/patch/filediff/EditTransformer.java
similarity index 62%
rename from java/com/google/gerrit/server/patch/EditTransformer.java
rename to java/com/google/gerrit/server/patch/filediff/EditTransformer.java
index 6288270..55568e4 100644
--- a/java/com/google/gerrit/server/patch/EditTransformer.java
+++ b/java/com/google/gerrit/server/patch/filediff/EditTransformer.java
@@ -12,20 +12,21 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.patch;
+package com.google.gerrit.server.patch.filediff;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.collect.Multimaps.toMultimap;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.MoreObjects;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.server.patch.DiffMappings;
+import com.google.gerrit.server.patch.GitPositionTransformer;
 import com.google.gerrit.server.patch.GitPositionTransformer.Mapping;
 import com.google.gerrit.server.patch.GitPositionTransformer.OmitPositionOnConflict;
 import com.google.gerrit.server.patch.GitPositionTransformer.Position;
@@ -36,7 +37,6 @@
 import java.util.Optional;
 import java.util.function.Function;
 import java.util.stream.Stream;
-import org.eclipse.jgit.diff.Edit;
 
 /**
  * Transformer of edits regarding their base trees. An edit describes a difference between {@code
@@ -54,40 +54,42 @@
 
   /**
    * Creates a new {@code EditTransformer} for the edits contained in the specified {@code
-   * PatchListEntry}s.
+   * FileEdits}s.
    *
-   * @param patchListEntries a list of {@code PatchListEntry}s containing the edits
+   * @param fileEdits a list of {@code FileEdits}s containing the edits
    */
-  public EditTransformer(List<PatchListEntry> patchListEntries) {
-    edits = patchListEntries.stream().flatMap(EditTransformer::toEdits).collect(toImmutableList());
+  public EditTransformer(List<FileEdits> fileEdits) {
+    // TODO(ghareeb): Can we replace FileEdits with another entity from the new refactored
+    // diff cache implementation? e.g. one of the GitFileDiffCache entities
+    edits = fileEdits.stream().flatMap(EditTransformer::toEdits).collect(toImmutableList());
   }
 
   /**
    * Transforms the references of side A of the edits. If the edits describe differences between
-   * {@code treeA} and {@code treeB} and the specified {@code PatchListEntry}s define a
-   * transformation from {@code treeA} to {@code treeA'}, the resulting edits will be defined as
-   * differences between {@code treeA'} and {@code treeB}. Edits which can't be transformed due to
-   * conflicts with the transformation are omitted.
+   * {@code treeA} and {@code treeB} and the specified {@code FileEdits}s define a transformation
+   * from {@code treeA} to {@code treeA'}, the resulting edits will be defined as differences
+   * between {@code treeA'} and {@code treeB}. Edits which can't be transformed due to conflicts
+   * with the transformation are omitted.
    *
-   * @param transformationEntries a list of {@code PatchListEntry}s defining the transformation of
-   *     {@code treeA} to {@code treeA'}
+   * @param transformingEntries a list of {@code FileEdits}s defining the transformation of {@code
+   *     treeA} to {@code treeA'}
    */
-  public void transformReferencesOfSideA(List<PatchListEntry> transformationEntries) {
-    transformEdits(transformationEntries, SideAStrategy.INSTANCE);
+  public void transformReferencesOfSideA(ImmutableList<FileEdits> transformingEntries) {
+    transformEdits(transformingEntries, SideAStrategy.INSTANCE);
   }
 
   /**
    * Transforms the references of side B of the edits. If the edits describe differences between
-   * {@code treeA} and {@code treeB} and the specified {@code PatchListEntry}s define a
-   * transformation from {@code treeB} to {@code treeB'}, the resulting edits will be defined as
-   * differences between {@code treeA} and {@code treeB'}. Edits which can't be transformed due to
-   * conflicts with the transformation are omitted.
+   * {@code treeA} and {@code treeB} and the specified {@code FileEdits}s define a transformation
+   * from {@code treeB} to {@code treeB'}, the resulting edits will be defined as differences
+   * between {@code treeA} and {@code treeB'}. Edits which can't be transformed due to conflicts
+   * with the transformation are omitted.
    *
-   * @param transformationEntries a list of {@code PatchListEntry}s defining the transformation of
+   * @param transformingEntries a list of {@code PatchListEntry}s defining the transformation of
    *     {@code treeB} to {@code treeB'}
    */
-  public void transformReferencesOfSideB(List<PatchListEntry> transformationEntries) {
-    transformEdits(transformationEntries, SideBStrategy.INSTANCE);
+  public void transformReferencesOfSideB(ImmutableList<FileEdits> transformingEntries) {
+    transformEdits(transformingEntries, SideBStrategy.INSTANCE);
   }
 
   /**
@@ -99,25 +101,33 @@
     return edits.stream()
         .collect(
             toMultimap(
-                ContextAwareEdit::getNewFilePath, Function.identity(), ArrayListMultimap::create));
+                c -> {
+                  String path =
+                      c.getNewFilePath().isPresent()
+                          ? c.getNewFilePath().get()
+                          : c.getOldFilePath().get();
+                  return path;
+                },
+                Function.identity(),
+                ArrayListMultimap::create));
   }
 
-  public static Stream<ContextAwareEdit> toEdits(PatchListEntry patchListEntry) {
-    ImmutableList<Edit> edits = patchListEntry.getEdits();
+  public static Stream<ContextAwareEdit> toEdits(FileEdits in) {
+    List<Edit> edits = in.edits();
     if (edits.isEmpty()) {
-      return Stream.of(ContextAwareEdit.createForNoContentEdit(patchListEntry));
+      return Stream.of(ContextAwareEdit.createForNoContentEdit(in.oldPath(), in.newPath()));
     }
 
-    return edits.stream().map(edit -> ContextAwareEdit.create(patchListEntry, edit));
+    return edits.stream().map(edit -> ContextAwareEdit.create(in.oldPath(), in.newPath(), edit));
   }
 
-  private void transformEdits(List<PatchListEntry> transformingEntries, SideStrategy sideStrategy) {
+  private void transformEdits(List<FileEdits> inputs, SideStrategy sideStrategy) {
     ImmutableList<PositionedEntity<ContextAwareEdit>> positionedEdits =
         edits.stream()
             .map(edit -> toPositionedEntity(edit, sideStrategy))
             .collect(toImmutableList());
     ImmutableSet<Mapping> mappings =
-        transformingEntries.stream().map(DiffMappings::toMapping).collect(toImmutableSet());
+        inputs.stream().map(DiffMappings::toMapping).collect(toImmutableSet());
 
     edits =
         positionTransformer.transform(positionedEdits, mappings).stream()
@@ -133,41 +143,41 @@
 
   @AutoValue
   abstract static class ContextAwareEdit {
-    static ContextAwareEdit create(PatchListEntry patchListEntry, Edit edit) {
+    static ContextAwareEdit create(Optional<String> oldPath, Optional<String> newPath, Edit edit) {
+      // TODO(ghareeb): Look if the new FileEdits class is capable of representing renames/copies
+      // and in this case we can get rid of the ContextAwareEdit class.
       return create(
-          patchListEntry.getOldName(),
-          patchListEntry.getNewName(),
-          edit.getBeginA(),
-          edit.getEndA(),
-          edit.getBeginB(),
-          edit.getEndB(),
-          false);
+          oldPath, newPath, edit.beginA(), edit.endA(), edit.beginB(), edit.endB(), false);
     }
 
-    static ContextAwareEdit createForNoContentEdit(PatchListEntry patchListEntry) {
+    static ContextAwareEdit createForNoContentEdit(
+        Optional<String> oldPath, Optional<String> newPath) {
       // Remove the warning in createEditAtNewPosition() if we switch to an empty range instead of
       // (-1:-1, -1:-1) in the future.
-      return create(
-          patchListEntry.getOldName(), patchListEntry.getNewName(), -1, -1, -1, -1, false);
+      return create(oldPath, newPath, -1, -1, -1, -1, false);
     }
 
     static ContextAwareEdit create(
-        String oldFilePath,
-        String newFilePath,
+        Optional<String> oldFilePath,
+        Optional<String> newFilePath,
         int beginA,
         int endA,
         int beginB,
         int endB,
         boolean filePathAdjusted) {
-      String adjustedOldFilePath = MoreObjects.firstNonNull(oldFilePath, newFilePath);
-      boolean implicitRename = !Objects.equals(oldFilePath, newFilePath) && filePathAdjusted;
+      Optional<String> adjustedFilePath = oldFilePath.isPresent() ? oldFilePath : newFilePath;
+      boolean implicitRename =
+          newFilePath.isPresent()
+              && oldFilePath.isPresent()
+              && !Objects.equals(oldFilePath.get(), newFilePath.get())
+              && filePathAdjusted;
       return new AutoValue_EditTransformer_ContextAwareEdit(
-          adjustedOldFilePath, newFilePath, beginA, endA, beginB, endB, implicitRename);
+          adjustedFilePath, newFilePath, beginA, endA, beginB, endB, implicitRename);
     }
 
-    public abstract String getOldFilePath();
+    public abstract Optional<String> getOldFilePath();
 
-    public abstract String getNewFilePath();
+    public abstract Optional<String> getNewFilePath();
 
     public abstract int getBeginA();
 
@@ -180,12 +190,13 @@
     // Used for equals(), for which this value is important.
     public abstract boolean isImplicitRename();
 
-    public Optional<Edit> toEdit() {
+    public Optional<org.eclipse.jgit.diff.Edit> toEdit() {
       if (getBeginA() < 0) {
         return Optional.empty();
       }
 
-      return Optional.of(new Edit(getBeginA(), getEndA(), getBeginB(), getEndB()));
+      return Optional.of(
+          new org.eclipse.jgit.diff.Edit(getBeginA(), getEndA(), getBeginB(), getEndB()));
     }
   }
 
@@ -200,8 +211,12 @@
 
     @Override
     public Position extractPosition(ContextAwareEdit edit) {
+      String filePath =
+          edit.getOldFilePath().isPresent()
+              ? edit.getOldFilePath().get()
+              : edit.getNewFilePath().get();
       return Position.builder()
-          .filePath(edit.getOldFilePath())
+          .filePath(filePath)
           .lineRange(Range.create(edit.getBeginA(), edit.getEndA()))
           .build();
     }
@@ -227,13 +242,13 @@
             newPosition);
       }
       return ContextAwareEdit.create(
-          updatedFilePath,
+          Optional.of(updatedFilePath),
           edit.getNewFilePath(),
           updatedRange.start(),
           updatedRange.end(),
           edit.getBeginB(),
           edit.getEndB(),
-          !Objects.equals(edit.getOldFilePath(), updatedFilePath));
+          !Objects.equals(edit.getOldFilePath(), Optional.of(updatedFilePath)));
     }
   }
 
@@ -242,8 +257,12 @@
 
     @Override
     public Position extractPosition(ContextAwareEdit edit) {
+      String filePath =
+          edit.getNewFilePath().isPresent()
+              ? edit.getNewFilePath().get()
+              : edit.getOldFilePath().get();
       return Position.builder()
-          .filePath(edit.getNewFilePath())
+          .filePath(filePath)
           .lineRange(Range.create(edit.getBeginB(), edit.getEndB()))
           .build();
     }
@@ -255,7 +274,8 @@
       // in the future.
       Range updatedRange = newPosition.lineRange().orElseGet(() -> Range.create(-1, -1));
       // Same as far the range above. PATCHSET_LEVEL is a safe fallback.
-      String updatedFilePath = newPosition.filePath().orElse(Patch.PATCHSET_LEVEL);
+      Optional<String> updatedFilePath =
+          Optional.of(newPosition.filePath().orElse(Patch.PATCHSET_LEVEL));
       return ContextAwareEdit.create(
           edit.getOldFilePath(),
           updatedFilePath,
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java
new file mode 100644
index 0000000..a9bcf03
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java
@@ -0,0 +1,45 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+
+/**
+ * This cache computes the git diff for a single file path and adds some extra logic, e.g. for
+ * identifying edits that are due to rebase.
+ */
+public interface FileDiffCache {
+  /**
+   * Returns the file diff for a single file path identified by its key.
+   *
+   * @param key identifies two git commits, a specific file path and other diff parameters.
+   * @return the file diff for a single file path identified by its key.
+   * @throws DiffNotAvailableException if the commit IDs of the key are invalid for this project or
+   *     if file contents could not be read.
+   */
+  FileDiffOutput get(FileDiffCacheKey key) throws DiffNotAvailableException;
+
+  /**
+   * Returns the file diff for a collection of file paths identified by their keys.
+   *
+   * @param keys identifying different file paths of different projects.
+   * @return a map of the input keys to their corresponding git file diffs.
+   * @throws DiffNotAvailableException if the diff failed to be evaluated for one or more of the
+   *     input keys due to invalid commit IDs or if file contents could not be read.
+   */
+  ImmutableMap<FileDiffCacheKey, FileDiffOutput> getAll(Iterable<FileDiffCacheKey> keys)
+      throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
new file mode 100644
index 0000000..395312f
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -0,0 +1,544 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.AutoMerger;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffUtil;
+import com.google.gerrit.server.patch.Text;
+import com.google.gerrit.server.patch.filediff.EditTransformer.ContextAwareEdit;
+import com.google.gerrit.server.patch.gitfilediff.FileHeaderUtil;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiff;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithmFactory;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.diff.EditList;
+import org.eclipse.jgit.diff.RawText;
+import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.patch.FileHeader;
+import org.eclipse.jgit.patch.FileHeader.PatchType;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Cache for the single file diff between two commits for a single file path. This cache adds extra
+ * Gerrit logic such as identifying edits due to rebase.
+ *
+ * <p>If the {@link FileDiffCacheKey#oldCommit()} is equal to {@link
+ * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the git diff will be evaluated against the empty
+ * tree.
+ */
+public class FileDiffCacheImpl implements FileDiffCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String DIFF = "gerrit_file_diff";
+
+  private final LoadingCache<FileDiffCacheKey, FileDiffOutput> cache;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(FileDiffCache.class).to(FileDiffCacheImpl.class);
+
+        factory(AllDiffsEvaluator.Factory.class);
+
+        persist(DIFF, FileDiffCacheKey.class, FileDiffOutput.class)
+            .maximumWeight(10 << 20)
+            .weigher(FileDiffWeigher.class)
+            .version(4)
+            .keySerializer(FileDiffCacheKey.Serializer.INSTANCE)
+            .valueSerializer(FileDiffOutput.Serializer.INSTANCE)
+            .loader(FileDiffLoader.class);
+      }
+    };
+  }
+
+  private enum MagicPath {
+    COMMIT,
+    MERGE_LIST
+  }
+
+  @Inject
+  public FileDiffCacheImpl(@Named(DIFF) LoadingCache<FileDiffCacheKey, FileDiffOutput> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public FileDiffOutput get(FileDiffCacheKey key) throws DiffNotAvailableException {
+    try {
+      return cache.get(key);
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  @Override
+  public ImmutableMap<FileDiffCacheKey, FileDiffOutput> getAll(Iterable<FileDiffCacheKey> keys)
+      throws DiffNotAvailableException {
+    try {
+      ImmutableMap<FileDiffCacheKey, FileDiffOutput> result = cache.getAll(keys);
+      if (result.size() != Iterables.size(keys)) {
+        throw new DiffNotAvailableException(
+            String.format(
+                "Failed to load the value for all %d keys. Returned "
+                    + "map contains only %d values",
+                Iterables.size(keys), result.size()));
+      }
+      return result;
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  static class FileDiffLoader extends CacheLoader<FileDiffCacheKey, FileDiffOutput> {
+    private final GitRepositoryManager repoManager;
+    private final AllDiffsEvaluator.Factory allDiffsEvaluatorFactory;
+
+    @Inject
+    FileDiffLoader(
+        AllDiffsEvaluator.Factory allDiffsEvaluatorFactory, GitRepositoryManager manager) {
+      this.allDiffsEvaluatorFactory = allDiffsEvaluatorFactory;
+      this.repoManager = manager;
+    }
+
+    @Override
+    public FileDiffOutput load(FileDiffCacheKey key) throws IOException, DiffNotAvailableException {
+      return loadAll(ImmutableList.of(key)).get(key);
+    }
+
+    @Override
+    public Map<FileDiffCacheKey, FileDiffOutput> loadAll(Iterable<? extends FileDiffCacheKey> keys)
+        throws DiffNotAvailableException {
+      ImmutableMap.Builder<FileDiffCacheKey, FileDiffOutput> result = ImmutableMap.builder();
+
+      Map<Project.NameKey, List<FileDiffCacheKey>> keysByProject =
+          Streams.stream(keys).distinct().collect(Collectors.groupingBy(FileDiffCacheKey::project));
+
+      for (Project.NameKey project : keysByProject.keySet()) {
+        List<FileDiffCacheKey> fileKeys = new ArrayList<>();
+
+        try (Repository repo = repoManager.openRepository(project);
+            ObjectReader reader = repo.newObjectReader();
+            RevWalk rw = new RevWalk(reader)) {
+
+          for (FileDiffCacheKey key : keysByProject.get(project)) {
+            if (key.newFilePath().equals(Patch.COMMIT_MSG)) {
+              result.put(key, createMagicPathEntry(key, reader, rw, MagicPath.COMMIT));
+            } else if (key.newFilePath().equals(Patch.MERGE_LIST)) {
+              result.put(key, createMagicPathEntry(key, reader, rw, MagicPath.MERGE_LIST));
+            } else {
+              fileKeys.add(key);
+            }
+          }
+          result.putAll(createFileEntries(reader, fileKeys, rw));
+        } catch (IOException e) {
+          logger.atWarning().log("Failed to open the repository %s: %s", project, e.getMessage());
+        }
+      }
+      return result.build();
+    }
+
+    private ComparisonType getComparisonType(
+        RevWalk rw, ObjectReader reader, ObjectId oldCommitId, ObjectId newCommitId)
+        throws IOException {
+      RevCommit oldCommit = DiffUtil.getRevCommit(rw, oldCommitId);
+      RevCommit newCommit = DiffUtil.getRevCommit(rw, newCommitId);
+      for (int i = 0; i < newCommit.getParentCount(); i++) {
+        if (newCommit.getParent(i).equals(oldCommit)) {
+          return ComparisonType.againstParent(i + 1);
+        }
+      }
+      // TODO(ghareeb): it's not trivial to distinguish if diff with old commit is against another
+      // patchset or auto-merge. Looking at the commit message of old commit gives a strong
+      // signal that we are diffing against auto-merge, though not 100% accurate (e.g. if old commit
+      // has the auto-merge prefix in the commit message). A better resolution would be to move the
+      // COMMIT_MSG and MERGE_LIST evaluations outside of the diff cache. For more details, see
+      // discussion in
+      // https://gerrit-review.googlesource.com/c/gerrit/+/280519/6..18/java/com/google/gerrit/server/patch/FileDiffCache.java#b540
+      String oldCommitMsgTxt = new String(Text.forCommit(reader, oldCommit).getContent(), UTF_8);
+      if (oldCommitMsgTxt.contains(AutoMerger.AUTO_MERGE_MSG_PREFIX)) {
+        return ComparisonType.againstAutoMerge();
+      }
+      return ComparisonType.againstOtherPatchSet();
+    }
+
+    /**
+     * Creates a {@link FileDiffOutput} entry for the "Commit message" and "Merge list" file paths.
+     */
+    private FileDiffOutput createMagicPathEntry(
+        FileDiffCacheKey key, ObjectReader reader, RevWalk rw, MagicPath magicPath) {
+      try {
+        RawTextComparator cmp = comparatorFor(key.whitespace());
+        ComparisonType comparisonType =
+            getComparisonType(rw, reader, key.oldCommit(), key.newCommit());
+        RevCommit aCommit = DiffUtil.getRevCommit(rw, key.oldCommit());
+        RevCommit bCommit = DiffUtil.getRevCommit(rw, key.newCommit());
+        return magicPath == MagicPath.COMMIT
+            ? createCommitEntry(reader, aCommit, bCommit, comparisonType, cmp, key.diffAlgorithm())
+            : createMergeListEntry(
+                reader, aCommit, bCommit, comparisonType, cmp, key.diffAlgorithm());
+      } catch (IOException e) {
+        logger.atWarning().log("Failed to compute commit entry for key %s", key);
+      }
+      return FileDiffOutput.empty(key.newFilePath(), key.oldCommit(), key.newCommit());
+    }
+
+    private static RawTextComparator comparatorFor(Whitespace ws) {
+      switch (ws) {
+        case IGNORE_ALL:
+          return RawTextComparator.WS_IGNORE_ALL;
+
+        case IGNORE_TRAILING:
+          return RawTextComparator.WS_IGNORE_TRAILING;
+
+        case IGNORE_LEADING_AND_TRAILING:
+          return RawTextComparator.WS_IGNORE_CHANGE;
+
+        case IGNORE_NONE:
+        default:
+          return RawTextComparator.DEFAULT;
+      }
+    }
+
+    private FileDiffOutput createCommitEntry(
+        ObjectReader reader,
+        RevCommit oldCommit,
+        RevCommit newCommit,
+        ComparisonType comparisonType,
+        RawTextComparator rawTextComparator,
+        GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
+        throws IOException {
+      Text aText =
+          comparisonType.isAgainstParentOrAutoMerge()
+              ? Text.EMPTY
+              : Text.forCommit(reader, oldCommit);
+      Text bText = Text.forCommit(reader, newCommit);
+      return createMagicFileDiffOutput(
+          oldCommit,
+          newCommit,
+          comparisonType,
+          rawTextComparator,
+          aText,
+          bText,
+          Patch.COMMIT_MSG,
+          diffAlgorithm);
+    }
+
+    private FileDiffOutput createMergeListEntry(
+        ObjectReader reader,
+        RevCommit oldCommit,
+        RevCommit newCommit,
+        ComparisonType comparisonType,
+        RawTextComparator rawTextComparator,
+        GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
+        throws IOException {
+      Text aText =
+          comparisonType.isAgainstParentOrAutoMerge()
+              ? Text.EMPTY
+              : Text.forMergeList(comparisonType, reader, oldCommit);
+      Text bText = Text.forMergeList(comparisonType, reader, newCommit);
+      return createMagicFileDiffOutput(
+          oldCommit,
+          newCommit,
+          comparisonType,
+          rawTextComparator,
+          aText,
+          bText,
+          Patch.MERGE_LIST,
+          diffAlgorithm);
+    }
+
+    private static FileDiffOutput createMagicFileDiffOutput(
+        ObjectId oldCommit,
+        ObjectId newCommit,
+        ComparisonType comparisonType,
+        RawTextComparator rawTextComparator,
+        Text aText,
+        Text bText,
+        String fileName,
+        GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm) {
+      byte[] rawHdr = getRawHeader(!comparisonType.isAgainstParentOrAutoMerge(), fileName);
+      byte[] aContent = aText.getContent();
+      byte[] bContent = bText.getContent();
+      long size = bContent.length;
+      long sizeDelta = size - aContent.length;
+      RawText aRawText = new RawText(aContent);
+      RawText bRawText = new RawText(bContent);
+      EditList edits =
+          DiffAlgorithmFactory.create(diffAlgorithm).diff(rawTextComparator, aRawText, bRawText);
+      FileHeader fileHeader = new FileHeader(rawHdr, edits, PatchType.UNIFIED);
+      Patch.ChangeType changeType = FileHeaderUtil.getChangeType(fileHeader);
+      return FileDiffOutput.builder()
+          .oldCommitId(oldCommit)
+          .newCommitId(newCommit)
+          .comparisonType(comparisonType)
+          .oldPath(FileHeaderUtil.getOldPath(fileHeader))
+          .newPath(FileHeaderUtil.getNewPath(fileHeader))
+          .changeType(changeType)
+          .patchType(Optional.of(FileHeaderUtil.getPatchType(fileHeader)))
+          .headerLines(FileHeaderUtil.getHeaderLines(fileHeader))
+          .edits(
+              asTaggedEdits(
+                  edits.stream().map(Edit::fromJGitEdit).collect(Collectors.toList()),
+                  ImmutableList.of()))
+          .size(size)
+          .sizeDelta(sizeDelta)
+          .build();
+    }
+
+    private static byte[] getRawHeader(boolean hasA, String fileName) {
+      StringBuilder hdr = new StringBuilder();
+      hdr.append("diff --git");
+      if (hasA) {
+        hdr.append(" a/").append(fileName);
+      } else {
+        hdr.append(" ").append(FileHeader.DEV_NULL);
+      }
+      hdr.append(" b/").append(fileName);
+      hdr.append("\n");
+
+      if (hasA) {
+        hdr.append("--- a/").append(fileName).append("\n");
+      } else {
+        hdr.append("--- ").append(FileHeader.DEV_NULL).append("\n");
+      }
+      hdr.append("+++ b/").append(fileName).append("\n");
+      return hdr.toString().getBytes(UTF_8);
+    }
+
+    private Map<FileDiffCacheKey, FileDiffOutput> createFileEntries(
+        ObjectReader reader, List<FileDiffCacheKey> keys, RevWalk rw)
+        throws DiffNotAvailableException, IOException {
+      Map<AugmentedFileDiffCacheKey, AllFileGitDiffs> allFileDiffs =
+          allDiffsEvaluatorFactory.create(rw).execute(wrapKeys(keys, rw));
+
+      Map<FileDiffCacheKey, FileDiffOutput> result = new HashMap<>();
+
+      for (AugmentedFileDiffCacheKey augmentedKey : allFileDiffs.keySet()) {
+        AllFileGitDiffs allDiffs = allFileDiffs.get(augmentedKey);
+
+        FileEdits rebaseFileEdits = FileEdits.empty();
+        if (!augmentedKey.ignoreRebase()) {
+          rebaseFileEdits = computeRebaseEdits(allDiffs);
+        }
+        List<Edit> rebaseEdits = rebaseFileEdits.edits();
+
+        RevTree aTree = rw.parseTree(allDiffs.mainDiff().gitKey().oldTree());
+        RevTree bTree = rw.parseTree(allDiffs.mainDiff().gitKey().newTree());
+        GitFileDiff mainGitDiff = allDiffs.mainDiff().gitDiff();
+
+        Long oldSize =
+            mainGitDiff.oldMode().isPresent() && mainGitDiff.oldPath().isPresent()
+                ? new FileSizeEvaluator(reader, aTree)
+                    .compute(
+                        mainGitDiff.oldId(),
+                        mainGitDiff.oldMode().get(),
+                        mainGitDiff.oldPath().get())
+                : 0;
+        Long newSize =
+            mainGitDiff.newMode().isPresent() && mainGitDiff.newPath().isPresent()
+                ? new FileSizeEvaluator(reader, bTree)
+                    .compute(
+                        mainGitDiff.newId(),
+                        mainGitDiff.newMode().get(),
+                        mainGitDiff.newPath().get())
+                : 0;
+
+        ObjectId oldCommit = augmentedKey.key().oldCommit();
+        ObjectId newCommit = augmentedKey.key().newCommit();
+        FileDiffOutput fileDiff =
+            FileDiffOutput.builder()
+                .oldCommitId(oldCommit)
+                .newCommitId(newCommit)
+                .comparisonType(getComparisonType(rw, reader, oldCommit, newCommit))
+                .changeType(mainGitDiff.changeType())
+                .patchType(mainGitDiff.patchType())
+                .oldPath(mainGitDiff.oldPath())
+                .newPath(mainGitDiff.newPath())
+                .headerLines(FileHeaderUtil.getHeaderLines(mainGitDiff.fileHeader()))
+                .edits(asTaggedEdits(mainGitDiff.edits(), rebaseEdits))
+                .size(newSize)
+                .sizeDelta(newSize - oldSize)
+                .build();
+
+        result.put(augmentedKey.key(), fileDiff);
+      }
+
+      return result;
+    }
+
+    /**
+     * Convert the list of input keys {@link FileDiffCacheKey} to a list of {@link
+     * AugmentedFileDiffCacheKey} that also include the old and new parent commit IDs, and a boolean
+     * that indicates whether we should include the rebase edits for each key.
+     *
+     * <p>The output list is expected to have the same size of the input list, i.e. we map all keys.
+     */
+    private List<AugmentedFileDiffCacheKey> wrapKeys(List<FileDiffCacheKey> keys, RevWalk rw) {
+      List<AugmentedFileDiffCacheKey> result = new ArrayList<>();
+      for (FileDiffCacheKey key : keys) {
+        if (key.oldCommit().equals(EMPTY_TREE_ID)) {
+          result.add(AugmentedFileDiffCacheKey.builder().key(key).ignoreRebase(true).build());
+          continue;
+        }
+        try {
+          RevCommit oldRevCommit = DiffUtil.getRevCommit(rw, key.oldCommit());
+          RevCommit newRevCommit = DiffUtil.getRevCommit(rw, key.newCommit());
+          if (!DiffUtil.areRelated(oldRevCommit, newRevCommit)) {
+            result.add(
+                AugmentedFileDiffCacheKey.builder()
+                    .key(key)
+                    .oldParentId(Optional.of(oldRevCommit.getParent(0).getId()))
+                    .newParentId(Optional.of(newRevCommit.getParent(0).getId()))
+                    .ignoreRebase(false)
+                    .build());
+          } else {
+            result.add(AugmentedFileDiffCacheKey.builder().key(key).ignoreRebase(true).build());
+          }
+        } catch (IOException e) {
+          logger.atWarning().log(
+              "Failed to evaluate commits relation for key "
+                  + key
+                  + ". Skipping this key: "
+                  + e.getMessage(),
+              e);
+          result.add(AugmentedFileDiffCacheKey.builder().key(key).ignoreRebase(true).build());
+        }
+      }
+      return result;
+    }
+
+    private static ImmutableList<TaggedEdit> asTaggedEdits(
+        List<Edit> normalEdits, List<Edit> rebaseEdits) {
+      Set<Edit> rebaseEditsSet = new HashSet<>(rebaseEdits);
+      ImmutableList.Builder<TaggedEdit> result =
+          ImmutableList.builderWithExpectedSize(normalEdits.size());
+      for (Edit e : normalEdits) {
+        result.add(TaggedEdit.create(e, rebaseEditsSet.contains(e)));
+      }
+      return result.build();
+    }
+
+    /**
+     * Computes the subset of edits that are due to rebase between 2 commits.
+     *
+     * <p>The input parameter {@link AllFileGitDiffs#mainDiff} contains all the edits in
+     * consideration. Of those, we identify the edits due to rebase as a function of:
+     *
+     * <ol>
+     *   <li>The edits between the old commit and its parent {@link
+     *       AllFileGitDiffs#oldVsParentDiff}.
+     *   <li>The edits between the new commit and its parent {@link
+     *       AllFileGitDiffs#newVsParentDiff}.
+     *   <li>The edits between the parents of the old commit and new commits {@link
+     *       AllFileGitDiffs#parentVsParentDiff}.
+     * </ol>
+     *
+     * @param diffs an entity containing 4 sets of edits: those between the old and new commit,
+     *     between the old and new commits vs. their parents, and between the old and new parents.
+     * @return the list of edits that are due to rebase.
+     */
+    private FileEdits computeRebaseEdits(AllFileGitDiffs diffs) {
+      if (!diffs.parentVsParentDiff().isPresent()) {
+        return FileEdits.empty();
+      }
+
+      GitFileDiff parentVsParentDiff = diffs.parentVsParentDiff().get().gitDiff();
+
+      EditTransformer editTransformer =
+          new EditTransformer(
+              ImmutableList.of(
+                  FileEdits.create(
+                      parentVsParentDiff.edits().stream().collect(toImmutableList()),
+                      parentVsParentDiff.oldPath(),
+                      parentVsParentDiff.newPath())));
+
+      if (diffs.oldVsParentDiff().isPresent()) {
+        GitFileDiff oldVsParDiff = diffs.oldVsParentDiff().get().gitDiff();
+        editTransformer.transformReferencesOfSideA(
+            ImmutableList.of(
+                FileEdits.create(
+                    oldVsParDiff.edits().stream().collect(toImmutableList()),
+                    oldVsParDiff.oldPath(),
+                    oldVsParDiff.newPath())));
+      }
+
+      if (diffs.newVsParentDiff().isPresent()) {
+        GitFileDiff newVsParDiff = diffs.newVsParentDiff().get().gitDiff();
+        editTransformer.transformReferencesOfSideB(
+            ImmutableList.of(
+                FileEdits.create(
+                    newVsParDiff.edits().stream().collect(toImmutableList()),
+                    newVsParDiff.oldPath(),
+                    newVsParDiff.newPath())));
+      }
+
+      Multimap<String, ContextAwareEdit> editsPerFilePath = editTransformer.getEditsPerFilePath();
+
+      if (editsPerFilePath.isEmpty()) {
+        return FileEdits.empty();
+      }
+
+      // editsPerFilePath is expected to have a single item representing the file
+      String filePath = editsPerFilePath.keys().iterator().next();
+      Collection<ContextAwareEdit> edits = editsPerFilePath.get(filePath);
+      return FileEdits.create(
+          edits.stream()
+              .map(ContextAwareEdit::toEdit)
+              .filter(Optional::isPresent)
+              .map(Optional::get)
+              .map(Edit::fromJGitEdit)
+              .collect(toImmutableList()),
+          edits.iterator().next().getOldFilePath(),
+          edits.iterator().next().getNewFilePath());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
new file mode 100644
index 0000000..a478fcf
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
@@ -0,0 +1,130 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.FileDiffKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Cache key for the {@link FileDiffCache}. */
+@AutoValue
+public abstract class FileDiffCacheKey {
+
+  /** A specific git project / repository. */
+  public abstract Project.NameKey project();
+
+  /** The 20 bytes SHA-1 commit ID of the old commit used in the diff. */
+  public abstract ObjectId oldCommit();
+
+  /** The 20 bytes SHA-1 commit ID of the new commit used in the diff. */
+  public abstract ObjectId newCommit();
+
+  /** File path identified by its name. */
+  public abstract String newFilePath();
+
+  /**
+   * Percentage score used to identify a file as a "rename". A special value of -1 means that the
+   * computation will ignore renames and rename detection will be disabled.
+   */
+  public abstract int renameScore();
+
+  /** The diff algorithm that should be used in the computation. */
+  public abstract DiffAlgorithm diffAlgorithm();
+
+  public abstract DiffPreferencesInfo.Whitespace whitespace();
+
+  /** Number of bytes that this entity occupies. */
+  public int weight() {
+    return stringSize(project().get())
+        + 20 * 2 // old and new commits
+        + stringSize(newFilePath())
+        + 4 // renameScore
+        + 4 // diffAlgorithm
+        + 4; // whitespace
+  }
+
+  public static FileDiffCacheKey.Builder builder() {
+    return new AutoValue_FileDiffCacheKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract FileDiffCacheKey.Builder project(NameKey value);
+
+    public abstract FileDiffCacheKey.Builder oldCommit(ObjectId value);
+
+    public abstract FileDiffCacheKey.Builder newCommit(ObjectId value);
+
+    public abstract FileDiffCacheKey.Builder newFilePath(String value);
+
+    public abstract FileDiffCacheKey.Builder renameScore(int value);
+
+    public FileDiffCacheKey.Builder disableRenameDetection() {
+      renameScore(-1);
+      return this;
+    }
+
+    public abstract FileDiffCacheKey.Builder diffAlgorithm(DiffAlgorithm value);
+
+    public abstract FileDiffCacheKey.Builder whitespace(Whitespace value);
+
+    public abstract FileDiffCacheKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<FileDiffCacheKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(FileDiffCacheKey key) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return Protos.toByteArray(
+          FileDiffKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setOldCommit(idConverter.toByteString(key.oldCommit()))
+              .setNewCommit(idConverter.toByteString(key.newCommit()))
+              .setFilePath(key.newFilePath())
+              .setRenameScore(key.renameScore())
+              .setDiffAlgorithm(key.diffAlgorithm().name())
+              .setWhitespace(key.whitespace().name())
+              .build());
+    }
+
+    @Override
+    public FileDiffCacheKey deserialize(byte[] in) {
+      FileDiffKeyProto proto = Protos.parseUnchecked(FileDiffKeyProto.parser(), in);
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return FileDiffCacheKey.builder()
+          .project(Project.nameKey(proto.getProject()))
+          .oldCommit(idConverter.fromByteString(proto.getOldCommit()))
+          .newCommit(idConverter.fromByteString(proto.getNewCommit()))
+          .newFilePath(proto.getFilePath())
+          .renameScore(proto.getRenameScore())
+          .diffAlgorithm(DiffAlgorithm.valueOf(proto.getDiffAlgorithm()))
+          .whitespace(Whitespace.valueOf(proto.getWhitespace()))
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
new file mode 100644
index 0000000..e7f47ef
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -0,0 +1,278 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.PatchType;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.FileDiffOutputProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import java.io.Serializable;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** File diff for a single file path. Produced as output of the {@link FileDiffCache}. */
+@AutoValue
+public abstract class FileDiffOutput implements Serializable {
+  private static final long serialVersionUID = 1L;
+
+  /** The 20 bytes SHA-1 object ID of the old git commit used in the diff. */
+  public abstract ObjectId oldCommitId();
+
+  /** The 20 bytes SHA-1 object ID of the new git commit used in the diff. */
+  public abstract ObjectId newCommitId();
+
+  /** Comparison type of old and new commits: against another patchset, parent or auto-merge. */
+  public abstract ComparisonType comparisonType();
+
+  /**
+   * The file path at the old commit. Returns an empty Optional if {@link #changeType()} is equal to
+   * {@link ChangeType#ADDED}.
+   */
+  public abstract Optional<String> oldPath();
+
+  /**
+   * The file path at the new commit. Returns an empty optional if {@link #changeType()} is equal to
+   * {@link ChangeType#DELETED}.
+   */
+  public abstract Optional<String> newPath();
+
+  /** The change type of the underlying file, e.g. added, deleted, renamed, etc... */
+  public abstract Patch.ChangeType changeType();
+
+  /** The patch type of the underlying file, e.g. unified, binary , etc... */
+  public abstract Optional<Patch.PatchType> patchType();
+
+  /**
+   * A list of strings representation of the header lines of the {@link
+   * org.eclipse.jgit.patch.FileHeader} that is produced as output of the diff.
+   */
+  public abstract ImmutableList<String> headerLines();
+
+  /** The list of edits resulting from the diff hunks of the file. */
+  public abstract ImmutableList<TaggedEdit> edits();
+
+  /** The file size at the new commit. */
+  public abstract long size();
+
+  /** Difference in file size between the old and new commits. */
+  public abstract long sizeDelta();
+
+  /** A boolean indicating if all underlying edits of the file diff are due to rebase. */
+  public boolean allEditsDueToRebase() {
+    return !edits().isEmpty() && edits().stream().allMatch(TaggedEdit::dueToRebase);
+  }
+
+  /** Returns the number of inserted lines for the file diff. */
+  public int insertions() {
+    int ins = 0;
+    for (TaggedEdit e : edits()) {
+      if (!e.dueToRebase()) {
+        ins += e.edit().endB() - e.edit().beginB();
+      }
+    }
+    return ins;
+  }
+
+  /** Returns the number of deleted lines for the file diff. */
+  public int deletions() {
+    int del = 0;
+    for (TaggedEdit e : edits()) {
+      if (!e.dueToRebase()) {
+        del += e.edit().endA() - e.edit().beginA();
+      }
+    }
+    return del;
+  }
+
+  /** Returns an entity representing an unchanged file between two commits. */
+  public static FileDiffOutput empty(String filePath, ObjectId oldCommitId, ObjectId newCommitId) {
+    return builder()
+        .oldCommitId(oldCommitId)
+        .newCommitId(newCommitId)
+        .comparisonType(ComparisonType.againstOtherPatchSet()) // not important
+        .oldPath(Optional.empty())
+        .newPath(Optional.of(filePath))
+        .changeType(ChangeType.MODIFIED)
+        .headerLines(ImmutableList.of())
+        .edits(ImmutableList.of())
+        .size(0)
+        .sizeDelta(0)
+        .build();
+  }
+
+  /** Returns true if this entity represents an unchanged file between two commits. */
+  public boolean isEmpty() {
+    return headerLines().isEmpty() && edits().isEmpty();
+  }
+
+  public static Builder builder() {
+    return new AutoValue_FileDiffOutput.Builder();
+  }
+
+  public int weight() {
+    int result = 0;
+    if (oldPath().isPresent()) {
+      result += stringSize(oldPath().get());
+    }
+    if (newPath().isPresent()) {
+      result += stringSize(newPath().get());
+    }
+    result += 20 + 20; // old and new commit IDs
+    result += 4; // comparison type
+    result += 4; // changeType
+    if (patchType().isPresent()) {
+      result += 4;
+    }
+    result += 4 + 4; // insertions and deletions
+    result += 4 + 4; // size and size delta
+    result += 20 * edits().size(); // each edit is 4 Integers + boolean = 4 * 4 + 4 = 20
+    for (String s : headerLines()) {
+      s += stringSize(s);
+    }
+    return result;
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder oldCommitId(ObjectId value);
+
+    public abstract Builder newCommitId(ObjectId value);
+
+    public abstract Builder comparisonType(ComparisonType value);
+
+    public abstract Builder oldPath(Optional<String> value);
+
+    public abstract Builder newPath(Optional<String> value);
+
+    public abstract Builder changeType(ChangeType value);
+
+    public abstract Builder patchType(Optional<PatchType> value);
+
+    public abstract Builder headerLines(ImmutableList<String> value);
+
+    public abstract Builder edits(ImmutableList<TaggedEdit> value);
+
+    public abstract Builder size(long value);
+
+    public abstract Builder sizeDelta(long value);
+
+    public abstract FileDiffOutput build();
+  }
+
+  public enum Serializer implements CacheSerializer<FileDiffOutput> {
+    INSTANCE;
+
+    private static final FieldDescriptor OLD_PATH_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(1);
+
+    private static final FieldDescriptor NEW_PATH_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(2);
+
+    private static final FieldDescriptor PATCH_TYPE_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(4);
+
+    @Override
+    public byte[] serialize(FileDiffOutput fileDiff) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      FileDiffOutputProto.Builder builder =
+          FileDiffOutputProto.newBuilder()
+              .setOldCommit(idConverter.toByteString(fileDiff.oldCommitId().toObjectId()))
+              .setNewCommit(idConverter.toByteString(fileDiff.newCommitId().toObjectId()))
+              .setComparisonType(fileDiff.comparisonType().toProto())
+              .setSize(fileDiff.size())
+              .setSizeDelta(fileDiff.sizeDelta())
+              .addAllHeaderLines(fileDiff.headerLines())
+              .setChangeType(fileDiff.changeType().name())
+              .addAllEdits(
+                  fileDiff.edits().stream()
+                      .map(
+                          e ->
+                              FileDiffOutputProto.TaggedEdit.newBuilder()
+                                  .setEdit(
+                                      FileDiffOutputProto.Edit.newBuilder()
+                                          .setBeginA(e.edit().beginA())
+                                          .setEndA(e.edit().endA())
+                                          .setBeginB(e.edit().beginB())
+                                          .setEndB(e.edit().endB())
+                                          .build())
+                                  .setDueToRebase(e.dueToRebase())
+                                  .build())
+                      .collect(Collectors.toList()));
+
+      if (fileDiff.oldPath().isPresent()) {
+        builder.setOldPath(fileDiff.oldPath().get());
+      }
+
+      if (fileDiff.newPath().isPresent()) {
+        builder.setNewPath(fileDiff.newPath().get());
+      }
+
+      if (fileDiff.patchType().isPresent()) {
+        builder.setPatchType(fileDiff.patchType().get().name());
+      }
+
+      return Protos.toByteArray(builder.build());
+    }
+
+    @Override
+    public FileDiffOutput deserialize(byte[] in) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      FileDiffOutputProto proto = Protos.parseUnchecked(FileDiffOutputProto.parser(), in);
+      FileDiffOutput.Builder builder = FileDiffOutput.builder();
+      builder
+          .oldCommitId(idConverter.fromByteString(proto.getOldCommit()))
+          .newCommitId(idConverter.fromByteString(proto.getNewCommit()))
+          .comparisonType(ComparisonType.fromProto(proto.getComparisonType()))
+          .size(proto.getSize())
+          .sizeDelta(proto.getSizeDelta())
+          .headerLines(proto.getHeaderLinesList().stream().collect(ImmutableList.toImmutableList()))
+          .changeType(ChangeType.valueOf(proto.getChangeType()))
+          .edits(
+              proto.getEditsList().stream()
+                  .map(
+                      e ->
+                          TaggedEdit.create(
+                              Edit.create(
+                                  e.getEdit().getBeginA(),
+                                  e.getEdit().getEndA(),
+                                  e.getEdit().getBeginB(),
+                                  e.getEdit().getEndB()),
+                              e.getDueToRebase()))
+                  .collect(ImmutableList.toImmutableList()));
+
+      if (proto.hasField(OLD_PATH_DESCRIPTOR)) {
+        builder.oldPath(Optional.of(proto.getOldPath()));
+      }
+      if (proto.hasField(NEW_PATH_DESCRIPTOR)) {
+        builder.newPath(Optional.of(proto.getNewPath()));
+      }
+      if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
+        builder.patchType(Optional.of(Patch.PatchType.valueOf(proto.getPatchType())));
+      }
+      return builder.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java b/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java
new file mode 100644
index 0000000..8eda234
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java
@@ -0,0 +1,29 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import com.google.common.cache.Weigher;
+
+/**
+ * A weigher for the {@link FileDiffCache} key and value. This is used by the cache backend to
+ * assign weights for cache entries and is used for evictions.
+ */
+public class FileDiffWeigher implements Weigher<FileDiffCacheKey, FileDiffOutput> {
+
+  @Override
+  public int weigh(FileDiffCacheKey key, FileDiffOutput fileDiffOutput) {
+    return key.weight() + fileDiffOutput.weight();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileEdits.java b/java/com/google/gerrit/server/patch/filediff/FileEdits.java
new file mode 100644
index 0000000..a009a02
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileEdits.java
@@ -0,0 +1,51 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import java.util.Optional;
+
+/**
+ * An entity class containing the list of edits between two commits for a file, and the old and new
+ * paths.
+ */
+@AutoValue
+public abstract class FileEdits {
+  public static FileEdits create(
+      ImmutableList<Edit> edits, Optional<String> oldPath, Optional<String> newPath) {
+    return new AutoValue_FileEdits(edits, oldPath, newPath);
+  }
+
+  public static FileEdits createFromJgitEdits(
+      ImmutableList<org.eclipse.jgit.diff.Edit> jgitEdits,
+      Optional<String> oldPath,
+      Optional<String> newPath) {
+    return new AutoValue_FileEdits(
+        jgitEdits.stream().map(Edit::fromJGitEdit).collect(toImmutableList()), oldPath, newPath);
+  }
+
+  public abstract ImmutableList<Edit> edits();
+
+  public abstract Optional<String> oldPath();
+
+  public abstract Optional<String> newPath();
+
+  public static FileEdits empty() {
+    return new AutoValue_FileEdits(ImmutableList.of(), Optional.empty(), Optional.empty());
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileSizeEvaluator.java b/java/com/google/gerrit/server/patch/filediff/FileSizeEvaluator.java
new file mode 100644
index 0000000..e2c1bc5
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileSizeEvaluator.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.FileMode;
+import com.google.gerrit.exceptions.StorageException;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Optional;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/** Helper class for computing the size of a file in a given git tree. */
+class FileSizeEvaluator {
+  private final ObjectReader reader;
+  private final RevTree tree;
+
+  FileSizeEvaluator(ObjectReader reader, RevTree tree) {
+    this.reader = reader;
+    this.tree = tree;
+  }
+
+  /**
+   * Computes the file size identified by the {@code path} parameter at the given git tree
+   * identified by {@code gitTreeId}.
+   */
+  long compute(AbbreviatedObjectId gitTreeId, Patch.FileMode mode, String path) throws IOException {
+    if (!isBlob(mode)) {
+      return 0;
+    }
+    ObjectId fileId =
+        toObjectId(reader, gitTreeId).orElseGet(() -> lookupObjectId(reader, path, tree));
+    if (ObjectId.zeroId().equals(fileId)) {
+      return 0;
+    }
+    return reader.getObjectSize(fileId, OBJ_BLOB);
+  }
+
+  private static ObjectId lookupObjectId(ObjectReader reader, String path, RevTree tree) {
+    // This variant is very expensive.
+    try (TreeWalk treeWalk = TreeWalk.forPath(reader, path, tree)) {
+      return treeWalk != null ? treeWalk.getObjectId(0) : ObjectId.zeroId();
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  private static Optional<ObjectId> toObjectId(
+      ObjectReader reader, @Nullable AbbreviatedObjectId abbreviatedId) throws IOException {
+    if (abbreviatedId == null) {
+      // In theory, DiffEntry#getOldId or DiffEntry#getNewId can be null for pure renames or pure
+      // mode changes (e.g. DiffEntry#modify doesn't set the IDs). However, the method we call for
+      // diffs (DiffFormatter#scan) seems to always produce DiffEntries with set IDs, even for pure
+      // renames.
+      return Optional.empty();
+    }
+    if (abbreviatedId.isComplete()) {
+      // With the current JGit version and the method we call for diffs (DiffFormatter#scan),
+      // this
+      // is the only code path taken right now.
+      return Optional.ofNullable(abbreviatedId.toObjectId());
+    }
+    Collection<ObjectId> objectIds = reader.resolve(abbreviatedId);
+    // It seems very unlikely that an ObjectId which was just abbreviated by the diff
+    // computation
+    // now can't be resolved to exactly one ObjectId. The API allows this possibility, though.
+    return objectIds.size() == 1
+        ? Optional.of(Iterables.getOnlyElement(objectIds))
+        : Optional.empty();
+  }
+
+  private static boolean isBlob(Patch.FileMode mode) {
+    return mode.equals(FileMode.REGULAR_FILE)
+        || mode.equals(FileMode.EXECUTABLE_FILE)
+        || mode.equals(FileMode.SYMLINK);
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/GitDiffEntity.java b/java/com/google/gerrit/server/patch/filediff/GitDiffEntity.java
new file mode 100644
index 0000000..2ca8fa6
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/GitDiffEntity.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiff;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheKey;
+
+/** An entity containing a {@link GitFileDiffCacheKey} and its loaded value {@link GitFileDiff}. */
+@AutoValue
+abstract class GitDiffEntity {
+  public static GitDiffEntity create(GitFileDiffCacheKey gitKey, GitFileDiff gitDiff) {
+    return new AutoValue_GitDiffEntity(gitKey, gitDiff);
+  }
+
+  abstract GitFileDiffCacheKey gitKey();
+
+  abstract GitFileDiff gitDiff();
+}
diff --git a/java/com/google/gerrit/server/patch/PatchListLoader.java b/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
similarity index 88%
rename from java/com/google/gerrit/server/patch/PatchListLoader.java
rename to java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
index be0895b..017e276 100644
--- a/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
@@ -1,19 +1,21 @@
-// Copyright (C) 2009 The Android Open Source Project
+//  Copyright (C) 2020 The Android Open Source Project
 //
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
 //
-// http://www.apache.org/licenses/LICENSE-2.0
+//  http://www.apache.org/licenses/LICENSE-2.0
 //
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+//
+//
 //
 
-package com.google.gerrit.server.patch;
+package com.google.gerrit.server.patch.filediff;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.collect.ImmutableList.toImmutableList;
@@ -38,7 +40,17 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.patch.EditTransformer.ContextAwareEdit;
+import com.google.gerrit.server.patch.AutoMerger;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.DiffExecutor;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListCacheImpl;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.Text;
+import com.google.gerrit.server.patch.filediff.EditTransformer.ContextAwareEdit;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -81,7 +93,7 @@
 import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 public class PatchListLoader implements Callable<PatchList> {
-  static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  public static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
     PatchListLoader create(PatchListKey key, Project.NameKey project);
@@ -95,7 +107,6 @@
   private final PatchListKey key;
   private final Project.NameKey project;
   private final long timeoutMillis;
-  private final boolean save;
 
   @Inject
   PatchListLoader(
@@ -121,13 +132,12 @@
             "timeout",
             TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
             TimeUnit.MILLISECONDS);
-    save = AutoMerger.cacheAutomerge(cfg);
   }
 
   @Override
   public PatchList call() throws IOException, PatchListNotAvailableException {
     try (Repository repo = repoManager.openRepository(project);
-        ObjectInserter ins = newInserter(repo);
+        InMemoryInserter ins = new InMemoryInserter(repo);
         ObjectReader reader = ins.newReader();
         RevWalk rw = new RevWalk(reader)) {
       return readPatchList(repo, rw, ins);
@@ -151,11 +161,7 @@
     }
   }
 
-  private ObjectInserter newInserter(Repository repo) {
-    return save ? repo.newObjectInserter() : new InMemoryInserter(repo);
-  }
-
-  private PatchList readPatchList(Repository repo, RevWalk rw, ObjectInserter ins)
+  private PatchList readPatchList(Repository repo, RevWalk rw, InMemoryInserter ins)
       throws IOException, PatchListNotAvailableException {
     ObjectReader reader = rw.getObjectReader();
     checkArgument(reader.getCreatedFromInserter() == ins);
@@ -306,13 +312,39 @@
         getRelevantPatchListEntries(
             parentDiffEntries, parentCommitA, parentCommitB, touchedFilePaths, df);
 
-    EditTransformer editTransformer = new EditTransformer(parentPatchListEntries);
-    editTransformer.transformReferencesOfSideA(oldPatches);
-    editTransformer.transformReferencesOfSideB(newPatches);
+    EditTransformer editTransformer = new EditTransformer(toFileEditsList(parentPatchListEntries));
+    editTransformer.transformReferencesOfSideA(toFileEditsList(oldPatches));
+    editTransformer.transformReferencesOfSideB(toFileEditsList(newPatches));
     return EditsDueToRebaseResult.create(
         relevantDiffEntries, editTransformer.getEditsPerFilePath());
   }
 
+  private ImmutableList<FileEdits> toFileEditsList(List<PatchListEntry> entries) {
+    return entries.stream().map(PatchListLoader::toFileEdits).collect(toImmutableList());
+  }
+
+  private static FileEdits toFileEdits(PatchListEntry patchListEntry) {
+    Optional<String> oldName = Optional.empty();
+    Optional<String> newName = Optional.empty();
+    switch (patchListEntry.getChangeType()) {
+      case DELETED:
+        oldName = Optional.of(patchListEntry.getNewName());
+        break;
+      case ADDED:
+      case MODIFIED:
+      case REWRITE:
+        newName = Optional.of(patchListEntry.getNewName());
+        break;
+
+      case COPIED:
+      case RENAMED:
+        oldName = Optional.of(patchListEntry.getOldName());
+        newName = Optional.of(patchListEntry.getNewName());
+        break;
+    }
+    return FileEdits.createFromJgitEdits(patchListEntry.getEdits(), oldName, newName);
+  }
+
   private static boolean isRootOrMergeCommit(RevCommit commit) {
     return commit.getParentCount() != 1;
   }
@@ -405,7 +437,7 @@
     PatchListEntry patchListEntry =
         newEntry(treeA, fileHeader, contentEditsDueToRebase, newSize, newSize - oldSize);
     // All edits in a file are due to rebase -> exclude the file from the diff.
-    if (EditTransformer.toEdits(patchListEntry).allMatch(editsDueToRebase::contains)) {
+    if (EditTransformer.toEdits(toFileEdits(patchListEntry)).allMatch(editsDueToRebase::contains)) {
       return Optional.empty();
     }
     return Optional.of(patchListEntry);
@@ -596,7 +628,7 @@
   }
 
   private RevObject aFor(
-      PatchListKey key, Repository repo, RevWalk rw, ObjectInserter ins, RevCommit b)
+      PatchListKey key, Repository repo, RevWalk rw, InMemoryInserter ins, RevCommit b)
       throws IOException {
     if (key.getOldId() != null) {
       return rw.parseAny(key.getOldId());
@@ -611,15 +643,16 @@
           rw.parseBody(r);
           return r;
         }
-      case 2:
+      default:
         if (key.getParentNum() != null) {
           RevCommit r = b.getParent(key.getParentNum() - 1);
           rw.parseBody(r);
           return r;
         }
-        return autoMerger.merge(repo, rw, ins, b, mergeStrategy);
-      default:
-        // TODO(sop) handle an octopus merge.
+        // Only support auto-merge for 2 parents, not octopus merges
+        if (b.getParentCount() == 2) {
+          return autoMerger.lookupFromGitOrMergeInMemory(repo, rw, ins, b, mergeStrategy);
+        }
         return null;
     }
   }
diff --git a/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java b/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
new file mode 100644
index 0000000..3720680
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
@@ -0,0 +1,37 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * An entity class encapsulating a JGit {@link Edit} along with extra attributes (e.g. identifying a
+ * rebase edit).
+ */
+@AutoValue
+public abstract class TaggedEdit {
+
+  public static TaggedEdit create(Edit edit, boolean dueToRebase) {
+    return new AutoValue_TaggedEdit(edit, dueToRebase);
+  }
+
+  public abstract Edit edit();
+
+  public org.eclipse.jgit.diff.Edit jgitEdit() {
+    return Edit.toJGitEdit(edit());
+  }
+
+  public abstract boolean dueToRebase();
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
new file mode 100644
index 0000000..d178f22
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
@@ -0,0 +1,40 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.gitdiff;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+
+/**
+ * A cache interface for identifying the list of Git modified files between 2 different git trees.
+ * This cache does not read the actual file contents, nor does it include the edits (modified
+ * regions) of the file.
+ *
+ * <p>The other {@link ModifiedFilesCache} is similar to this cache, and includes other extra Gerrit
+ * logic that we need to add with the list of modified files.
+ */
+public interface GitModifiedFilesCache {
+
+  /**
+   * Computes the list of of {@link ModifiedFile}s between the 2 git trees.
+   *
+   * @param key used to identify two git trees and contains other attributes to control the diff
+   *     calculation.
+   * @return the list of {@link ModifiedFile}s between the 2 git trees identified by the key.
+   * @throws DiffNotAvailableException trees cannot be read or file contents cannot be read.
+   */
+  ImmutableList<ModifiedFile> get(GitModifiedFilesCacheKey key) throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
new file mode 100644
index 0000000..b3b82bb
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
@@ -0,0 +1,177 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.gitdiff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache.ModifiedFilesProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
+
+/** Implementation of the {@link GitModifiedFilesCache} */
+public class GitModifiedFilesCacheImpl implements GitModifiedFilesCache {
+  private static final String GIT_MODIFIED_FILES = "git_modified_files";
+  private static final ImmutableMap<ChangeType, Patch.ChangeType> changeTypeMap =
+      ImmutableMap.of(
+          DiffEntry.ChangeType.ADD,
+          Patch.ChangeType.ADDED,
+          DiffEntry.ChangeType.MODIFY,
+          Patch.ChangeType.MODIFIED,
+          DiffEntry.ChangeType.DELETE,
+          Patch.ChangeType.DELETED,
+          DiffEntry.ChangeType.RENAME,
+          Patch.ChangeType.RENAMED,
+          DiffEntry.ChangeType.COPY,
+          Patch.ChangeType.COPIED);
+
+  private LoadingCache<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(GitModifiedFilesCache.class).to(GitModifiedFilesCacheImpl.class);
+
+        persist(
+                GIT_MODIFIED_FILES,
+                GitModifiedFilesCacheKey.class,
+                new TypeLiteral<ImmutableList<ModifiedFile>>() {})
+            .keySerializer(GitModifiedFilesCacheKey.Serializer.INSTANCE)
+            .valueSerializer(ValueSerializer.INSTANCE)
+            // The documentation has some defaults and recommendations for setting the cache
+            // attributes:
+            // https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#cache.
+            .maximumWeight(10 << 20)
+            .weigher(GitModifiedFilesWeigher.class)
+            // The cache is using the default disk limit as per section cache.<name>.diskLimit
+            // in the cache documentation link.
+            .version(1)
+            .loader(GitModifiedFilesCacheImpl.Loader.class);
+      }
+    };
+  }
+
+  @Inject
+  public GitModifiedFilesCacheImpl(
+      @Named(GIT_MODIFIED_FILES)
+          LoadingCache<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public ImmutableList<ModifiedFile> get(GitModifiedFilesCacheKey key)
+      throws DiffNotAvailableException {
+    try {
+      return cache.get(key);
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  static class Loader extends CacheLoader<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+    private final GitRepositoryManager repoManager;
+
+    @Inject
+    Loader(GitRepositoryManager repoManager) {
+      this.repoManager = repoManager;
+    }
+
+    @Override
+    public ImmutableList<ModifiedFile> load(GitModifiedFilesCacheKey key) throws IOException {
+      try (Repository repo = repoManager.openRepository(key.project());
+          ObjectReader reader = repo.newObjectReader()) {
+        List<DiffEntry> entries = getGitTreeDiff(repo, reader, key);
+
+        return entries.stream().map(Loader::toModifiedFile).collect(toImmutableList());
+      }
+    }
+
+    private List<DiffEntry> getGitTreeDiff(
+        Repository repo, ObjectReader reader, GitModifiedFilesCacheKey key) throws IOException {
+      try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+        df.setReader(reader, repo.getConfig());
+        if (key.renameDetection()) {
+          df.setDetectRenames(true);
+          df.getRenameDetector().setRenameScore(key.renameScore());
+        }
+        // The scan method only returns the file paths that are different. Callers may choose to
+        // format these paths themselves.
+        return df.scan(key.aTree(), key.bTree());
+      }
+    }
+
+    private static ModifiedFile toModifiedFile(DiffEntry entry) {
+      String oldPath = entry.getOldPath();
+      String newPath = entry.getNewPath();
+      return ModifiedFile.builder()
+          .changeType(toChangeType(entry.getChangeType()))
+          .oldPath(oldPath.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(oldPath))
+          .newPath(newPath.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(newPath))
+          .build();
+    }
+
+    private static Patch.ChangeType toChangeType(DiffEntry.ChangeType changeType) {
+      if (!changeTypeMap.containsKey(changeType)) {
+        throw new IllegalArgumentException("Unsupported type " + changeType);
+      }
+      return changeTypeMap.get(changeType);
+    }
+  }
+
+  public enum ValueSerializer implements CacheSerializer<ImmutableList<ModifiedFile>> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(ImmutableList<ModifiedFile> modifiedFiles) {
+      ModifiedFilesProto.Builder builder = ModifiedFilesProto.newBuilder();
+      modifiedFiles.forEach(
+          f -> builder.addModifiedFile(ModifiedFile.Serializer.INSTANCE.toProto(f)));
+      return Protos.toByteArray(builder.build());
+    }
+
+    @Override
+    public ImmutableList<ModifiedFile> deserialize(byte[] in) {
+      ImmutableList.Builder<ModifiedFile> modifiedFiles = ImmutableList.builder();
+      ModifiedFilesProto modifiedFilesProto =
+          Protos.parseUnchecked(ModifiedFilesProto.parser(), in);
+      modifiedFilesProto
+          .getModifiedFileList()
+          .forEach(f -> modifiedFiles.add(ModifiedFile.Serializer.INSTANCE.fromProto(f)));
+      return modifiedFiles.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
new file mode 100644
index 0000000..fb8fce1
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch.gitdiff;
+
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.GitModifiedFilesKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.DiffUtil;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Cache key for the {@link GitModifiedFilesCache}. */
+@AutoValue
+public abstract class GitModifiedFilesCacheKey {
+
+  /** A specific git project / repository. */
+  public abstract Project.NameKey project();
+
+  /**
+   * The git SHA-1 {@link ObjectId} of the first git tree object for which the diff should be
+   * computed.
+   */
+  public abstract ObjectId aTree();
+
+  /**
+   * The git SHA-1 {@link ObjectId} of the second git tree object for which the diff should be
+   * computed.
+   */
+  public abstract ObjectId bTree();
+
+  /**
+   * Percentage score used to identify a file as a rename. This value is only available if {@link
+   * #renameDetection()} is true. Otherwise, this method will return -1.
+   *
+   * <p>This value will be used to set the rename score of {@link
+   * org.eclipse.jgit.diff.DiffFormatter#getRenameDetector()}.
+   */
+  public abstract int renameScore();
+
+  /** Returns true if rename detection was set for this key. */
+  public boolean renameDetection() {
+    return renameScore() != -1;
+  }
+
+  public static GitModifiedFilesCacheKey create(
+      Project.NameKey project, ObjectId aCommit, ObjectId bCommit, int renameScore, RevWalk rw)
+      throws IOException {
+    ObjectId aTree = DiffUtil.getTreeId(rw, aCommit);
+    ObjectId bTree = DiffUtil.getTreeId(rw, bCommit);
+    return builder().project(project).aTree(aTree).bTree(bTree).renameScore(renameScore).build();
+  }
+
+  public static Builder builder() {
+    return new AutoValue_GitModifiedFilesCacheKey.Builder();
+  }
+
+  /** Returns the size of the object in bytes */
+  public int weight() {
+    return stringSize(project().get())
+        + 20 * 2 // old and new tree IDs
+        + 4; // rename score
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder project(NameKey value);
+
+    public abstract Builder aTree(ObjectId value);
+
+    public abstract Builder bTree(ObjectId value);
+
+    public abstract Builder renameScore(int value);
+
+    public Builder disableRenameDetection() {
+      renameScore(-1);
+      return this;
+    }
+
+    public abstract GitModifiedFilesCacheKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<GitModifiedFilesCacheKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(GitModifiedFilesCacheKey key) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return Protos.toByteArray(
+          GitModifiedFilesKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setATree(idConverter.toByteString(key.aTree()))
+              .setBTree(idConverter.toByteString(key.bTree()))
+              .setRenameScore(key.renameScore())
+              .build());
+    }
+
+    @Override
+    public GitModifiedFilesCacheKey deserialize(byte[] in) {
+      GitModifiedFilesKeyProto proto = Protos.parseUnchecked(GitModifiedFilesKeyProto.parser(), in);
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return GitModifiedFilesCacheKey.builder()
+          .project(NameKey.parse(proto.getProject()))
+          .aTree(idConverter.fromByteString(proto.getATree()))
+          .bTree(idConverter.fromByteString(proto.getBTree()))
+          .renameScore(proto.getRenameScore())
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
new file mode 100644
index 0000000..a678379
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
@@ -0,0 +1,26 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.gitdiff;
+
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+
+public class GitModifiedFilesWeigher
+    implements Weigher<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+  @Override
+  public int weigh(GitModifiedFilesCacheKey key, ImmutableList<ModifiedFile> modifiedFiles) {
+    return key.weight() + modifiedFiles.stream().mapToInt(ModifiedFile::weight).sum();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
new file mode 100644
index 0000000..9512094
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
@@ -0,0 +1,123 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.gitdiff;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.ModifiedFileProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import java.util.Optional;
+
+/**
+ * An entity representing a Modified file due to a diff between 2 git trees. This entity contains
+ * the change type and the old & new paths, but does not include any actual content diff of the
+ * file.
+ */
+@AutoValue
+public abstract class ModifiedFile {
+  /**
+   * Returns the change type (i.e. add, delete, modify, rename, etc...) associated with this
+   * modified file.
+   */
+  public abstract ChangeType changeType();
+
+  /**
+   * Returns the old name associated with this file. An empty optional is returned if {@link
+   * #changeType()} is equal to {@link ChangeType#ADDED}.
+   */
+  public abstract Optional<String> oldPath();
+
+  /**
+   * Returns the new name associated with this file. An empty optional is returned if {@link
+   * #changeType()} is equal to {@link ChangeType#DELETED}
+   */
+  public abstract Optional<String> newPath();
+
+  public static Builder builder() {
+    return new AutoValue_ModifiedFile.Builder();
+  }
+
+  /** Computes this object's weight, which is its size in bytes. */
+  public int weight() {
+    int weight = 1; // the changeType field
+    if (oldPath().isPresent()) {
+      weight += oldPath().get().length();
+    }
+    if (newPath().isPresent()) {
+      weight += newPath().get().length();
+    }
+    return weight;
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder changeType(ChangeType value);
+
+    public abstract Builder oldPath(Optional<String> value);
+
+    public abstract Builder newPath(Optional<String> value);
+
+    public abstract ModifiedFile build();
+  }
+
+  enum Serializer implements CacheSerializer<ModifiedFile> {
+    INSTANCE;
+
+    private static final FieldDescriptor oldPathDescriptor =
+        ModifiedFileProto.getDescriptor().findFieldByNumber(2);
+
+    private static final FieldDescriptor newPathDescriptor =
+        ModifiedFileProto.getDescriptor().findFieldByNumber(3);
+
+    @Override
+    public byte[] serialize(ModifiedFile modifiedFile) {
+      return Protos.toByteArray(toProto(modifiedFile));
+    }
+
+    public ModifiedFileProto toProto(ModifiedFile modifiedFile) {
+      ModifiedFileProto.Builder builder = ModifiedFileProto.newBuilder();
+      builder.setChangeType(modifiedFile.changeType().toString());
+      if (modifiedFile.oldPath().isPresent()) {
+        builder.setOldPath(modifiedFile.oldPath().get());
+      }
+      if (modifiedFile.newPath().isPresent()) {
+        builder.setNewPath(modifiedFile.newPath().get());
+      }
+      return builder.build();
+    }
+
+    @Override
+    public ModifiedFile deserialize(byte[] in) {
+      ModifiedFileProto modifiedFileProto = Protos.parseUnchecked(ModifiedFileProto.parser(), in);
+      return fromProto(modifiedFileProto);
+    }
+
+    public ModifiedFile fromProto(ModifiedFileProto modifiedFileProto) {
+      ModifiedFile.Builder builder = ModifiedFile.builder();
+      builder.changeType(ChangeType.valueOf(modifiedFileProto.getChangeType()));
+
+      if (modifiedFileProto.hasField(oldPathDescriptor)) {
+        builder.oldPath(Optional.of(modifiedFileProto.getOldPath()));
+      }
+      if (modifiedFileProto.hasField(newPathDescriptor)) {
+        builder.newPath(Optional.of(modifiedFileProto.getNewPath()));
+      }
+      return builder.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java b/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
new file mode 100644
index 0000000..7454f81
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
@@ -0,0 +1,198 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.gitfilediff;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.PatchType;
+import java.util.Optional;
+import org.eclipse.jgit.patch.CombinedFileHeader;
+import org.eclipse.jgit.patch.FileHeader;
+import org.eclipse.jgit.util.IntList;
+import org.eclipse.jgit.util.RawParseUtils;
+
+/** A utility class for the {@link FileHeader} JGit object */
+public class FileHeaderUtil {
+  private static final Byte NUL = '\0';
+
+  /**
+   * The maximum number of characters to lookup in the binary file {@link FileHeader}. This is used
+   * to scan the file header for the occurrence of the {@link #NUL} character.
+   *
+   * <p>This limit assumes a uniform distribution of all characters, hence the probability of the
+   * occurrence of each character = (1 / 256). We want to find the limit that makes the prob. of
+   * finding {@link #NUL} > 0.999. 1 - (255 / 256) ^ N > 0.999 yields N = 1766. We set the limit to
+   * this value multiplied by 10 for more confidence.
+   */
+  private static final int BIN_FILE_MAX_SCAN_LIMIT = 20000;
+
+  /** Converts the {@link FileHeader} parameter to a String representation. */
+  static String toString(FileHeader header) {
+    return new String(FileHeaderUtil.toByteArray(header), UTF_8);
+  }
+
+  /** Converts the {@link FileHeader} parameter to a byte array. */
+  static byte[] toByteArray(FileHeader header) {
+    int end = getEndOffset(header);
+    if (header.getStartOffset() == 0 && end == header.getBuffer().length) {
+      return header.getBuffer();
+    }
+
+    final byte[] buf = new byte[end - header.getStartOffset()];
+    System.arraycopy(header.getBuffer(), header.getStartOffset(), buf, 0, buf.length);
+    return buf;
+  }
+
+  /** Splits the {@code FileHeader} string to a list of strings, one string per header line. */
+  public static ImmutableList<String> getHeaderLines(FileHeader fileHeader) {
+    String fileHeaderString = toString(fileHeader);
+    return getHeaderLines(fileHeaderString);
+  }
+
+  public static ImmutableList<String> getHeaderLines(String header) {
+    return getHeaderLines(header.getBytes(UTF_8));
+  }
+
+  static ImmutableList<String> getHeaderLines(byte[] header) {
+    final IntList lineStartOffsets = RawParseUtils.lineMap(header, 0, header.length);
+    final ImmutableList.Builder<String> headerLines =
+        ImmutableList.builderWithExpectedSize(lineStartOffsets.size() - 1);
+    for (int i = 1; i < lineStartOffsets.size() - 1; i++) {
+      final int b = lineStartOffsets.get(i);
+      int e = lineStartOffsets.get(i + 1);
+      if (header[e - 1] == '\n') {
+        e--;
+      }
+      headerLines.add(RawParseUtils.decode(UTF_8, header, b, e));
+    }
+    return headerLines.build();
+  }
+
+  /**
+   * Returns the old file path associated with the {@link FileHeader}, or empty if the file is
+   * {@link com.google.gerrit.entities.Patch.ChangeType#ADDED} or {@link
+   * com.google.gerrit.entities.Patch.ChangeType#REWRITE}.
+   */
+  public static Optional<String> getOldPath(FileHeader header) {
+    Patch.ChangeType changeType = getChangeType(header);
+    switch (changeType) {
+      case DELETED:
+      case COPIED:
+      case RENAMED:
+      case MODIFIED:
+        return Optional.of(header.getOldPath());
+
+      case ADDED:
+      case REWRITE:
+        return Optional.empty();
+    }
+    return Optional.empty();
+  }
+
+  /**
+   * Returns the new file path associated with the {@link FileHeader}, or empty if the file is
+   * {@link com.google.gerrit.entities.Patch.ChangeType#DELETED}.
+   */
+  public static Optional<String> getNewPath(FileHeader header) {
+    Patch.ChangeType changeType = getChangeType(header);
+    switch (changeType) {
+      case DELETED:
+        return Optional.empty();
+
+      case ADDED:
+      case MODIFIED:
+      case REWRITE:
+      case COPIED:
+      case RENAMED:
+        return Optional.of(header.getNewPath());
+    }
+    return Optional.empty();
+  }
+
+  /** Returns the change type associated with the file header. */
+  public static Patch.ChangeType getChangeType(FileHeader header) {
+    // In Gerrit, we define our own entities  of the JGit entities, so that we have full control
+    // over their behaviors (e.g. making sure that these entities are immutable so that we can add
+    // them as fields of keys / values of persisted caches).
+
+    // TODO(ghareeb): remove the dead code of the value REWRITE and all its handling
+    switch (header.getChangeType()) {
+      case ADD:
+        return Patch.ChangeType.ADDED;
+      case MODIFY:
+        return Patch.ChangeType.MODIFIED;
+      case DELETE:
+        return Patch.ChangeType.DELETED;
+      case RENAME:
+        return Patch.ChangeType.RENAMED;
+      case COPY:
+        return Patch.ChangeType.COPIED;
+      default:
+        throw new IllegalArgumentException("Unsupported type " + header.getChangeType());
+    }
+  }
+
+  public static PatchType getPatchType(FileHeader header) {
+    PatchType patchType;
+
+    switch (header.getPatchType()) {
+      case UNIFIED:
+        patchType = Patch.PatchType.UNIFIED;
+        break;
+      case GIT_BINARY:
+      case BINARY:
+        patchType = Patch.PatchType.BINARY;
+        break;
+      default:
+        throw new IllegalArgumentException("Unsupported type " + header.getPatchType());
+    }
+
+    if (patchType != PatchType.BINARY) {
+      byte[] buf = header.getBuffer();
+      // TODO(ghareeb): should we adjust the max limit threshold?
+      // JGit sometimes misses the detection of binary files. In this case we look into the file
+      // header for the occurrence of NUL characters, which is a definite signal that the file is
+      // binary. We limit the number of characters to lookup to avoid performance bottlenecks.
+      for (int ptr = header.getStartOffset();
+          ptr < Math.min(header.getEndOffset(), BIN_FILE_MAX_SCAN_LIMIT);
+          ptr++) {
+        if (buf[ptr] == NUL) {
+          // It's really binary, but Git couldn't see the nul early enough to realize its binary,
+          // and instead produced the diff.
+          //
+          // Force it to be a binary; it really should have been that.
+          return PatchType.BINARY;
+        }
+      }
+    }
+    return patchType;
+  }
+
+  /**
+   * Returns the end offset of the diff header line of the {@code FileHeader parameter} before the
+   * appearance of any file edits (diff hunks).
+   */
+  private static int getEndOffset(FileHeader fileHeader) {
+    if (fileHeader instanceof CombinedFileHeader) {
+      return fileHeader.getEndOffset();
+    }
+    if (!fileHeader.getHunks().isEmpty()) {
+      return fileHeader.getHunks().get(0).getStartOffset();
+    }
+    return fileHeader.getEndOffset();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
new file mode 100644
index 0000000..e1af81d
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -0,0 +1,285 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.gitfilediff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.PatchType;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.GitFileDiffProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.filediff.Edit;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import java.io.IOException;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.patch.FileHeader;
+
+/**
+ * Entity representing a modified file (added, deleted, modified, renamed, etc...) between two
+ * different git commits.
+ */
+@AutoValue
+public abstract class GitFileDiff {
+  private static final Map<FileMode, Patch.FileMode> fileModeMap =
+      ImmutableMap.<FileMode, Patch.FileMode>builder()
+          .put(FileMode.TREE, Patch.FileMode.TREE)
+          .put(FileMode.SYMLINK, Patch.FileMode.SYMLINK)
+          .put(FileMode.GITLINK, Patch.FileMode.GITLINK)
+          .put(FileMode.REGULAR_FILE, Patch.FileMode.REGULAR_FILE)
+          .put(FileMode.EXECUTABLE_FILE, Patch.FileMode.EXECUTABLE_FILE)
+          .put(FileMode.MISSING, Patch.FileMode.MISSING)
+          .build();
+
+  private static Patch.FileMode mapFileMode(FileMode jgitFileMode) {
+    if (!fileModeMap.containsKey(jgitFileMode)) {
+      throw new IllegalArgumentException("Unsupported type " + jgitFileMode);
+    }
+    return fileModeMap.get(jgitFileMode);
+  }
+
+  /**
+   * Creates a {@link GitFileDiff} using the {@code diffEntry} and the {@code diffFormatter}
+   * parameters.
+   */
+  static GitFileDiff create(DiffEntry diffEntry, DiffFormatter diffFormatter) throws IOException {
+    FileHeader fileHeader = diffFormatter.toFileHeader(diffEntry);
+    ImmutableList<Edit> edits =
+        fileHeader.toEditList().stream().map(Edit::fromJGitEdit).collect(toImmutableList());
+
+    return builder()
+        .edits(edits)
+        .oldId(diffEntry.getOldId())
+        .newId(diffEntry.getNewId())
+        .fileHeader(FileHeaderUtil.toString(fileHeader))
+        .oldPath(FileHeaderUtil.getOldPath(fileHeader))
+        .newPath(FileHeaderUtil.getNewPath(fileHeader))
+        .changeType(FileHeaderUtil.getChangeType(fileHeader))
+        .patchType(Optional.of(FileHeaderUtil.getPatchType(fileHeader)))
+        .oldMode(Optional.of(mapFileMode(diffEntry.getOldMode())))
+        .newMode(Optional.of(mapFileMode(diffEntry.getNewMode())))
+        .build();
+  }
+
+  /**
+   * Represents an empty file diff, which means that the file was not modified between the two git
+   * trees identified by {@link #oldId()} and {@link #newId()}.
+   *
+   * @param newFilePath the file name at the {@link #newId()} git tree.
+   */
+  static GitFileDiff empty(
+      AbbreviatedObjectId oldId, AbbreviatedObjectId newId, String newFilePath) {
+    return builder()
+        .oldId(oldId)
+        .newId(newId)
+        .newPath(Optional.of(newFilePath))
+        .changeType(ChangeType.MODIFIED)
+        .edits(ImmutableList.of())
+        .fileHeader("")
+        .build();
+  }
+
+  /** An {@link ImmutableList} of the modified regions in the file. */
+  public abstract ImmutableList<Edit> edits();
+
+  /** A string representation of the {@link org.eclipse.jgit.patch.FileHeader}. */
+  public abstract String fileHeader();
+
+  /** The file name at the old git tree identified by {@link #oldId()} */
+  public abstract Optional<String> oldPath();
+
+  /** The file name at the new git tree identified by {@link #newId()} */
+  public abstract Optional<String> newPath();
+
+  /** The 20 bytes SHA-1 object ID of the old git tree of the diff. */
+  public abstract AbbreviatedObjectId oldId();
+
+  /** The 20 bytes SHA-1 object ID of the new git tree of the diff. */
+  public abstract AbbreviatedObjectId newId();
+
+  /** The file mode of the old file at the old git tree diff identified by {@link #oldId()}. */
+  public abstract Optional<Patch.FileMode> oldMode();
+
+  /** The file mode of the new file at the new git tree diff identified by {@link #newId()}. */
+  public abstract Optional<Patch.FileMode> newMode();
+
+  /** The change type associated with the file. */
+  public abstract ChangeType changeType();
+
+  /** The patch type associated with the file. */
+  public abstract Optional<PatchType> patchType();
+
+  /**
+   * Returns true if the object was created using the {@link #empty(AbbreviatedObjectId,
+   * AbbreviatedObjectId, String)} method.
+   */
+  public boolean isEmpty() {
+    return edits().isEmpty();
+  }
+
+  /** Returns the size of the object in bytes. */
+  public int weight() {
+    int result = 20 * 2; // oldId and newId
+    result += 16 * edits().size(); // each edit contains 4 integers (hence 16 bytes)
+    result += stringSize(fileHeader());
+    if (oldPath().isPresent()) {
+      result += stringSize(oldPath().get());
+    }
+    if (newPath().isPresent()) {
+      result += stringSize(newPath().get());
+    }
+    result += 4;
+    if (patchType().isPresent()) {
+      result += 4;
+    }
+    if (oldMode().isPresent()) {
+      result += 4;
+    }
+    if (newMode().isPresent()) {
+      result += 4;
+    }
+    return result;
+  }
+
+  public static Builder builder() {
+    return new AutoValue_GitFileDiff.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder edits(ImmutableList<Edit> value);
+
+    public abstract Builder fileHeader(String value);
+
+    public abstract Builder oldPath(Optional<String> value);
+
+    public abstract Builder newPath(Optional<String> value);
+
+    public abstract Builder oldId(AbbreviatedObjectId value);
+
+    public abstract Builder newId(AbbreviatedObjectId value);
+
+    public abstract Builder oldMode(Optional<Patch.FileMode> value);
+
+    public abstract Builder newMode(Optional<Patch.FileMode> value);
+
+    public abstract Builder changeType(ChangeType value);
+
+    public abstract Builder patchType(Optional<PatchType> value);
+
+    public abstract GitFileDiff build();
+  }
+
+  public enum Serializer implements CacheSerializer<GitFileDiff> {
+    INSTANCE;
+
+    private static final FieldDescriptor OLD_PATH_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(3);
+
+    private static final FieldDescriptor NEW_PATH_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(4);
+
+    private static final FieldDescriptor OLD_MODE_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(7);
+
+    private static final FieldDescriptor NEW_MODE_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(8);
+
+    private static final FieldDescriptor PATCH_TYPE_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(10);
+
+    @Override
+    public byte[] serialize(GitFileDiff gitFileDiff) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      GitFileDiffProto.Builder builder =
+          GitFileDiffProto.newBuilder()
+              .setFileHeader(gitFileDiff.fileHeader())
+              .setOldId(idConverter.toByteString(gitFileDiff.oldId().toObjectId()))
+              .setNewId(idConverter.toByteString(gitFileDiff.newId().toObjectId()))
+              .setChangeType(gitFileDiff.changeType().name());
+      gitFileDiff
+          .edits()
+          .forEach(
+              e ->
+                  builder.addEdits(
+                      GitFileDiffProto.Edit.newBuilder()
+                          .setBeginA(e.beginA())
+                          .setEndA(e.endA())
+                          .setBeginB(e.beginB())
+                          .setEndB(e.endB())));
+      if (gitFileDiff.oldPath().isPresent()) {
+        builder.setOldPath(gitFileDiff.oldPath().get());
+      }
+      if (gitFileDiff.newPath().isPresent()) {
+        builder.setNewPath(gitFileDiff.newPath().get());
+      }
+      if (gitFileDiff.oldMode().isPresent()) {
+        builder.setOldMode(gitFileDiff.oldMode().get().name());
+      }
+      if (gitFileDiff.newMode().isPresent()) {
+        builder.setNewMode(gitFileDiff.newMode().get().name());
+      }
+      if (gitFileDiff.patchType().isPresent()) {
+        builder.setPatchType(gitFileDiff.patchType().get().name());
+      }
+      return Protos.toByteArray(builder.build());
+    }
+
+    @Override
+    public GitFileDiff deserialize(byte[] in) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      GitFileDiffProto proto = Protos.parseUnchecked(GitFileDiffProto.parser(), in);
+      GitFileDiff.Builder builder = GitFileDiff.builder();
+      builder
+          .edits(
+              proto.getEditsList().stream()
+                  .map(e -> Edit.create(e.getBeginA(), e.getEndA(), e.getBeginB(), e.getEndB()))
+                  .collect(toImmutableList()))
+          .fileHeader(proto.getFileHeader())
+          .oldId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getOldId())))
+          .newId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getNewId())))
+          .changeType(ChangeType.valueOf(proto.getChangeType()));
+
+      if (proto.hasField(OLD_PATH_DESCRIPTOR)) {
+        builder.oldPath(Optional.of(proto.getOldPath()));
+      }
+      if (proto.hasField(NEW_PATH_DESCRIPTOR)) {
+        builder.newPath(Optional.of(proto.getNewPath()));
+      }
+      if (proto.hasField(OLD_MODE_DESCRIPTOR)) {
+        builder.oldMode(Optional.of(Patch.FileMode.valueOf(proto.getOldMode())));
+      }
+      if (proto.hasField(NEW_MODE_DESCRIPTOR)) {
+        builder.newMode(Optional.of(Patch.FileMode.valueOf(proto.getNewMode())));
+      }
+      if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
+        builder.patchType(Optional.of(Patch.PatchType.valueOf(proto.getPatchType())));
+      }
+      return builder.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java
new file mode 100644
index 0000000..2516761
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java
@@ -0,0 +1,43 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.gitfilediff;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+
+/** This cache computes pure git diff for a single file path according to a git tree diff. */
+public interface GitFileDiffCache {
+
+  /**
+   * Returns the git file diff for a single file path identified by its key.
+   *
+   * @param key identifies two git trees, a specific file path and other diff parameters.
+   * @return the file diff for a single file path identified by its key.
+   * @throws DiffNotAvailableException if the tree IDs of the key are invalid for this project or if
+   *     file contents could not be read.
+   */
+  GitFileDiff get(GitFileDiffCacheKey key) throws DiffNotAvailableException;
+
+  /**
+   * Returns the file diff for a collection of file paths identified by their keys.
+   *
+   * @param keys identifying different file paths of different projects.
+   * @return a map of the input keys to their corresponding git file diffs.
+   * @throws DiffNotAvailableException if the diff failed to be evaluated for one or more of the
+   *     input keys due to invalid tree IDs or if file contents could not be read.
+   */
+  ImmutableMap<GitFileDiffCacheKey, GitFileDiff> getAll(Iterable<GitFileDiffCacheKey> keys)
+      throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
new file mode 100644
index 0000000..97cf37d32
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -0,0 +1,273 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.gitfilediff;
+
+import static java.util.function.Function.identity;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.diff.HistogramDiff;
+import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
+
+/** Implementation of the {@link GitFileDiffCache} */
+public class GitFileDiffCacheImpl implements GitFileDiffCache {
+  private static final String GIT_DIFF = "git_file_diff";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(GitFileDiffCache.class).to(GitFileDiffCacheImpl.class);
+        persist(GIT_DIFF, GitFileDiffCacheKey.class, GitFileDiff.class)
+            .maximumWeight(10 << 20)
+            .weigher(GitFileDiffWeigher.class)
+            .keySerializer(GitFileDiffCacheKey.Serializer.INSTANCE)
+            .valueSerializer(GitFileDiff.Serializer.INSTANCE)
+            .loader(GitFileDiffCacheImpl.Loader.class);
+      }
+    };
+  }
+
+  /** Enum for the supported diff algorithms for the file diff computation. */
+  public enum DiffAlgorithm {
+    HISTOGRAM,
+    HISTOGRAM_WITHOUT_MYERS_FALLBACK
+  }
+
+  /** Creates a new JGit diff algorithm instance using the Gerrit's {@link DiffAlgorithm} enum. */
+  public static class DiffAlgorithmFactory {
+    public static org.eclipse.jgit.diff.DiffAlgorithm create(DiffAlgorithm diffAlgorithm) {
+      HistogramDiff result = new HistogramDiff();
+      if (diffAlgorithm.equals(DiffAlgorithm.HISTOGRAM_WITHOUT_MYERS_FALLBACK)) {
+        result.setFallbackAlgorithm(null);
+      }
+      return result;
+    }
+  }
+
+  private final LoadingCache<GitFileDiffCacheKey, GitFileDiff> cache;
+
+  @Inject
+  public GitFileDiffCacheImpl(
+      @Named(GIT_DIFF) LoadingCache<GitFileDiffCacheKey, GitFileDiff> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public GitFileDiff get(GitFileDiffCacheKey key) throws DiffNotAvailableException {
+    try {
+      return cache.get(key);
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  @Override
+  public ImmutableMap<GitFileDiffCacheKey, GitFileDiff> getAll(Iterable<GitFileDiffCacheKey> keys)
+      throws DiffNotAvailableException {
+    try {
+      return cache.getAll(keys);
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  static class Loader extends CacheLoader<GitFileDiffCacheKey, GitFileDiff> {
+    /**
+     * Extractor for the file path from a {@link DiffEntry}. Returns the old file path if the entry
+     * corresponds to a deleted file, otherwise it returns the new file path.
+     */
+    private static final Function<DiffEntry, String> pathExtractor =
+        (DiffEntry entry) ->
+            entry.getChangeType().equals(ChangeType.DELETE)
+                ? entry.getOldPath()
+                : entry.getNewPath();
+
+    private final GitRepositoryManager repoManager;
+
+    @Inject
+    public Loader(GitRepositoryManager repoManager) {
+      this.repoManager = repoManager;
+    }
+
+    @Override
+    public GitFileDiff load(GitFileDiffCacheKey key) throws IOException {
+      return loadAll(ImmutableList.of(key)).get(key);
+    }
+
+    @Override
+    public Map<GitFileDiffCacheKey, GitFileDiff> loadAll(
+        Iterable<? extends GitFileDiffCacheKey> keys) throws IOException {
+      ImmutableMap.Builder<GitFileDiffCacheKey, GitFileDiff> result =
+          ImmutableMap.builderWithExpectedSize(Iterables.size(keys));
+
+      Map<Project.NameKey, List<GitFileDiffCacheKey>> byProject =
+          Streams.stream(keys)
+              .distinct()
+              .collect(Collectors.groupingBy(GitFileDiffCacheKey::project));
+
+      for (Map.Entry<Project.NameKey, List<GitFileDiffCacheKey>> entry : byProject.entrySet()) {
+        try (Repository repo = repoManager.openRepository(entry.getKey());
+            ObjectReader reader = repo.newObjectReader()) {
+
+          // Grouping keys by diff options because each group of keys will be processed with a
+          // separate call to JGit using the DiffFormatter object.
+          Map<DiffOptions, List<GitFileDiffCacheKey>> optionsGroups =
+              entry.getValue().stream().collect(Collectors.groupingBy(DiffOptions::fromKey));
+
+          for (Map.Entry<DiffOptions, List<GitFileDiffCacheKey>> group : optionsGroups.entrySet()) {
+            result.putAll(loadAllImpl(repo, reader, group.getKey(), group.getValue()));
+          }
+        }
+      }
+      return result.build();
+    }
+
+    /**
+     * Loads the git file diffs for all keys of the same repository, and having the same diff {@code
+     * options}.
+     *
+     * @return The git file diffs for all input keys.
+     */
+    private Map<GitFileDiffCacheKey, GitFileDiff> loadAllImpl(
+        Repository repo, ObjectReader reader, DiffOptions options, List<GitFileDiffCacheKey> keys)
+        throws IOException {
+      ImmutableMap.Builder<GitFileDiffCacheKey, GitFileDiff> result =
+          ImmutableMap.builderWithExpectedSize(keys.size());
+      Map<GitFileDiffCacheKey, String> filePaths =
+          keys.stream().collect(Collectors.toMap(identity(), GitFileDiffCacheKey::newFilePath));
+      DiffFormatter formatter = createDiffFormatter(options, repo, reader);
+      Map<String, DiffEntry> diffEntries = loadDiffEntries(formatter, options, filePaths.values());
+      for (GitFileDiffCacheKey key : filePaths.keySet()) {
+        String newFilePath = filePaths.get(key);
+        if (diffEntries.containsKey(newFilePath)) {
+          result.put(key, GitFileDiff.create(diffEntries.get(newFilePath), formatter));
+          continue;
+        }
+        result.put(
+            key,
+            GitFileDiff.empty(
+                AbbreviatedObjectId.fromObjectId(key.oldTree()),
+                AbbreviatedObjectId.fromObjectId(key.newTree()),
+                newFilePath));
+      }
+      return result.build();
+    }
+
+    private static Map<String, DiffEntry> loadDiffEntries(
+        DiffFormatter diffFormatter, DiffOptions diffOptions, Collection<String> filePaths)
+        throws IOException {
+      Set<String> filePathsSet = ImmutableSet.copyOf(filePaths);
+      List<DiffEntry> diffEntries =
+          diffFormatter.scan(diffOptions.oldTree(), diffOptions.newTree());
+
+      return diffEntries.stream()
+          .filter(d -> filePathsSet.contains(pathExtractor.apply(d)))
+          .collect(Collectors.toMap(d -> pathExtractor.apply(d), identity()));
+    }
+
+    private static DiffFormatter createDiffFormatter(
+        DiffOptions diffOptions, Repository repo, ObjectReader reader) {
+      try (DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+        diffFormatter.setReader(reader, repo.getConfig());
+        RawTextComparator cmp = comparatorFor(diffOptions.whitespace());
+        diffFormatter.setDiffComparator(cmp);
+        if (diffOptions.renameScore() != -1) {
+          diffFormatter.setDetectRenames(true);
+          diffFormatter.getRenameDetector().setRenameScore(diffOptions.renameScore());
+        }
+        diffFormatter.setDiffAlgorithm(DiffAlgorithmFactory.create(diffOptions.diffAlgorithm()));
+        return diffFormatter;
+      }
+    }
+
+    private static RawTextComparator comparatorFor(Whitespace ws) {
+      switch (ws) {
+        case IGNORE_ALL:
+          return RawTextComparator.WS_IGNORE_ALL;
+
+        case IGNORE_TRAILING:
+          return RawTextComparator.WS_IGNORE_TRAILING;
+
+        case IGNORE_LEADING_AND_TRAILING:
+          return RawTextComparator.WS_IGNORE_CHANGE;
+
+        case IGNORE_NONE:
+        default:
+          return RawTextComparator.DEFAULT;
+      }
+    }
+  }
+
+  /** An entity representing the options affecting the diff computation. */
+  @AutoValue
+  abstract static class DiffOptions {
+    /** Convert a {@link GitFileDiffCacheKey} input to a {@link DiffOptions}. */
+    static DiffOptions fromKey(GitFileDiffCacheKey key) {
+      return create(
+          key.oldTree(), key.newTree(), key.renameScore(), key.whitespace(), key.diffAlgorithm());
+    }
+
+    private static DiffOptions create(
+        ObjectId oldTree,
+        ObjectId newTree,
+        Integer renameScore,
+        Whitespace whitespace,
+        DiffAlgorithm diffAlgorithm) {
+      return new AutoValue_GitFileDiffCacheImpl_DiffOptions(
+          oldTree, newTree, renameScore, whitespace, diffAlgorithm);
+    }
+
+    abstract ObjectId oldTree();
+
+    abstract ObjectId newTree();
+
+    abstract Integer renameScore();
+
+    abstract Whitespace whitespace();
+
+    abstract DiffAlgorithm diffAlgorithm();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
new file mode 100644
index 0000000..f104388
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch.gitfilediff;
+
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.GitFileDiffKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import org.eclipse.jgit.lib.ObjectId;
+
+@AutoValue
+public abstract class GitFileDiffCacheKey {
+
+  /** A specific git project / repository. */
+  public abstract Project.NameKey project();
+
+  /** The old 20 bytes SHA-1 git tree ID used in the git tree diff */
+  public abstract ObjectId oldTree();
+
+  /** The new 20 bytes SHA-1 git tree ID used in the git tree diff */
+  public abstract ObjectId newTree();
+
+  /** File name in the tree identified by {@link #newTree()} */
+  public abstract String newFilePath();
+
+  /**
+   * Percentage score used to identify a file as a "rename". A special value of -1 means that the
+   * computation will ignore renames and rename detection will be disabled.
+   */
+  public abstract int renameScore();
+
+  public abstract DiffAlgorithm diffAlgorithm();
+
+  public abstract DiffPreferencesInfo.Whitespace whitespace();
+
+  public int weight() {
+    return stringSize(project().get())
+        + 20 * 2 // oldTree and newTree
+        + stringSize(newFilePath())
+        + 4 // renameScore
+        + 4 // diffAlgorithm
+        + 4; // whitespace
+  }
+
+  public static Builder builder() {
+    return new AutoValue_GitFileDiffCacheKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder project(NameKey value);
+
+    public abstract Builder oldTree(ObjectId value);
+
+    public abstract Builder newTree(ObjectId value);
+
+    public abstract Builder newFilePath(String value);
+
+    public abstract Builder renameScore(Integer value);
+
+    public Builder disableRenameDetection() {
+      renameScore(-1);
+      return this;
+    }
+
+    public abstract Builder diffAlgorithm(DiffAlgorithm value);
+
+    public abstract Builder whitespace(Whitespace value);
+
+    public abstract GitFileDiffCacheKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<GitFileDiffCacheKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(GitFileDiffCacheKey key) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return Protos.toByteArray(
+          GitFileDiffKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setATree(idConverter.toByteString(key.oldTree()))
+              .setBTree(idConverter.toByteString(key.newTree()))
+              .setFilePath(key.newFilePath())
+              .setRenameScore(key.renameScore())
+              .setDiffAlgorithm(key.diffAlgorithm().name())
+              .setWhitepsace(key.whitespace().name())
+              .build());
+    }
+
+    @Override
+    public GitFileDiffCacheKey deserialize(byte[] in) {
+      GitFileDiffKeyProto proto = Protos.parseUnchecked(GitFileDiffKeyProto.parser(), in);
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return GitFileDiffCacheKey.builder()
+          .project(Project.nameKey(proto.getProject()))
+          .oldTree(idConverter.fromByteString(proto.getATree()))
+          .newTree(idConverter.fromByteString(proto.getBTree()))
+          .newFilePath(proto.getFilePath())
+          .renameScore(proto.getRenameScore())
+          .diffAlgorithm(DiffAlgorithm.valueOf(proto.getDiffAlgorithm()))
+          .whitespace(Whitespace.valueOf(proto.getWhitepsace()))
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java
new file mode 100644
index 0000000..47f7791
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java
@@ -0,0 +1,25 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.gitfilediff;
+
+import com.google.common.cache.Weigher;
+
+public class GitFileDiffWeigher implements Weigher<GitFileDiffCacheKey, GitFileDiff> {
+
+  @Override
+  public int weigh(GitFileDiffCacheKey key, GitFileDiff gitFileDiff) {
+    return key.weight() + gitFileDiff.weight();
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index cf6a184..66299a8 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -49,8 +49,6 @@
 public class DefaultPermissionBackend extends PermissionBackend {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final CurrentUser.PropertyKey<Boolean> IS_ADMIN = CurrentUser.PropertyKey.create();
-
   private final Provider<CurrentUser> currentUser;
   private final ProjectCache projectCache;
   private final ProjectControl.Factory projectControlFactory;
@@ -84,8 +82,15 @@
 
   @Override
   public WithUser absentUser(Account.Id id) {
-    IdentifiedUser identifiedUser = identifiedUserFactory.create(requireNonNull(id, "user"));
-    return new WithUserImpl(identifiedUser);
+    requireNonNull(id, "user");
+    Optional<Account.Id> user = getAccountIdOfIdentifiedUser();
+    if (user.isPresent() && id.equals(user.get())) {
+      // What looked liked an absent user is actually the current caller. Use the per-request
+      // singleton IdentifiedUser instead of constructing a new object to leverage caching in member
+      // variables of IdentifiedUser.
+      return new WithUserImpl(currentUser.get().asIdentifiedUser());
+    }
+    return new WithUserImpl(identifiedUserFactory.create(requireNonNull(id, "user")));
   }
 
   @Override
@@ -93,6 +98,21 @@
     return true;
   }
 
+  /**
+   * Returns the {@link com.google.gerrit.entities.Account.Id} of the current user if a user is
+   * signed in. Catches exceptions so that background jobs don't get impacted.
+   */
+  private Optional<Account.Id> getAccountIdOfIdentifiedUser() {
+    try {
+      return currentUser.get().isIdentifiedUser()
+          ? Optional.of(currentUser.get().getAccountId())
+          : Optional.empty();
+    } catch (Exception e) {
+      logger.atFine().withCause(e).log("Unable to get current user");
+      return Optional.empty();
+    }
+  }
+
   class WithUserImpl extends WithUser {
     private final CurrentUser user;
     private Boolean admin;
@@ -202,21 +222,13 @@
     }
 
     private Boolean computeAdmin() {
-      Optional<Boolean> r = user.get(IS_ADMIN);
-      if (r.isPresent()) {
-        return r.get();
-      }
-
-      boolean isAdmin;
       if (user.isImpersonating()) {
-        isAdmin = false;
-      } else if (user instanceof PeerDaemonUser) {
-        isAdmin = true;
-      } else {
-        isAdmin = allow(capabilities().administrateServer);
+        return false;
       }
-      user.put(IS_ADMIN, isAdmin);
-      return isAdmin;
+      if (user instanceof PeerDaemonUser) {
+        return true;
+      }
+      return allow(capabilities().administrateServer);
     }
 
     private boolean canEmailReviewers() {
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
index 3f84dff..d2e85be 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
@@ -31,6 +31,7 @@
       // TODO(hiesel) Hide ProjectControl, RefControl, ChangeControl related bindings.
       factory(ProjectControl.Factory.class);
       factory(DefaultRefFilter.Factory.class);
+      factory(VisibleChangesCache.Factory.class);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 03d3b63..1b528d7 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static java.util.stream.Collectors.toCollection;
 
@@ -23,12 +23,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -43,19 +39,15 @@
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
@@ -70,7 +62,6 @@
 
   private final TagCache tagCache;
   private final ChangeNotes.Factory changeNotesFactory;
-  @Nullable private final SearchingChangeCacheImpl changeCache;
   private final PermissionBackend permissionBackend;
   private final RefVisibilityControl refVisibilityControl;
   private final ProjectControl projectControl;
@@ -80,27 +71,28 @@
   private final Counter0 fullFilterCount;
   private final Counter0 skipFilterCount;
   private final boolean skipFullRefEvaluationIfAllRefsAreVisible;
+  private final VisibleChangesCache.Factory visibleChangesCacheFactory;
 
-  private Map<Change.Id, BranchNameKey> visibleChanges;
+  private VisibleChangesCache visibleChangesCache;
 
   @Inject
   DefaultRefFilter(
       TagCache tagCache,
       ChangeNotes.Factory changeNotesFactory,
-      @Nullable SearchingChangeCacheImpl changeCache,
       PermissionBackend permissionBackend,
       RefVisibilityControl refVisibilityControl,
       @GerritServerConfig Config config,
       MetricMaker metricMaker,
+      VisibleChangesCache.Factory visibleChangesCacheFactory,
       @Assisted ProjectControl projectControl) {
     this.tagCache = tagCache;
     this.changeNotesFactory = changeNotesFactory;
-    this.changeCache = changeCache;
     this.permissionBackend = permissionBackend;
     this.refVisibilityControl = refVisibilityControl;
     this.skipFullRefEvaluationIfAllRefsAreVisible =
         config.getBoolean("auth", "skipFullRefEvaluationIfAllRefsAreVisible", true);
     this.projectControl = projectControl;
+    this.visibleChangesCacheFactory = visibleChangesCacheFactory;
 
     this.user = projectControl.getUser();
     this.projectState = projectControl.getProjectState();
@@ -122,11 +114,12 @@
   /** Filters given refs and tags by visibility. */
   Collection<Ref> filter(Collection<Ref> refs, Repository repo, RefFilterOptions opts)
       throws PermissionBackendException {
+    visibleChangesCache = visibleChangesCacheFactory.create(projectControl, repo);
     logger.atFinest().log(
         "Filter refs for repository %s by visibility (options = %s, refs = %s)",
         projectState.getNameKey(), opts, refs);
     logger.atFinest().log("Calling user: %s", user.getLoggableName());
-    logger.atFinest().log("Groups: %s", user.getEffectiveGroups().getKnownGroups());
+    logger.atFinest().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
     logger.atFinest().log(
         "auth.skipFullRefEvaluationIfAllRefsAreVisible = %s",
         skipFullRefEvaluationIfAllRefsAreVisible);
@@ -155,11 +148,11 @@
     // Perform an initial ref filtering with all the refs the caller asked for. If we find tags that
     // we have to investigate separately (deferred tags) then perform a reachability check starting
     // from all visible branches (refs/heads/*).
-    Result initialRefFilter = filterRefs(new ArrayList<>(refs), repo, opts);
+    Result initialRefFilter = filterRefs(new ArrayList<>(refs), opts);
     List<Ref> visibleRefs = initialRefFilter.visibleRefs();
     if (!initialRefFilter.deferredTags().isEmpty()) {
       try (TraceTimer traceTimer = TraceContext.newTimer("Check visibility of deferred tags")) {
-        Result allVisibleBranches = filterRefs(getTaggableRefs(repo), repo, opts);
+        Result allVisibleBranches = filterRefs(getTaggableRefs(repo), opts);
         checkState(
             allVisibleBranches.deferredTags().isEmpty(),
             "unexpected tags found when filtering refs/heads/* "
@@ -193,8 +186,7 @@
    * separately for later rev-walk-based visibility computation. Tags where visibility is trivial to
    * compute will be returned as part of {@link Result#visibleRefs()}.
    */
-  Result filterRefs(List<Ref> refs, Repository repo, RefFilterOptions opts)
-      throws PermissionBackendException {
+  Result filterRefs(List<Ref> refs, RefFilterOptions opts) throws PermissionBackendException {
     logger.atFinest().log("Filter refs (refs = %s)", refs);
 
     // TODO(hiesel): Remove when optimization is done.
@@ -255,12 +247,13 @@
       } else if ((changeId = Change.Id.fromRef(refName)) != null) {
         // This is a mere performance optimization. RefVisibilityControl could determine the
         // visibility of these refs just fine. But instead, we use highly-optimized logic that
-        // looks only on the last 10k most recent changes using the change index and a cache.
+        // looks only on the available changes in the change index and cache (which are the
+        // most recent changes).
         if (hasAccessDatabase) {
           resultRefs.add(ref);
-        } else if (!visible(repo, changeId)) {
+        } else if (!visibleChangesCache.isVisible(changeId)) {
           logger.atFinest().log("Filter out invisible change ref %s", refName);
-        } else if (RefNames.isRefsEdit(refName) && !visibleEdit(repo, refName)) {
+        } else if (RefNames.isRefsEdit(refName) && !visibleEdit(refName)) {
           logger.atFinest().log("Filter out invisible change edit ref %s", refName);
         } else {
           // Change is visible
@@ -292,10 +285,7 @@
                       && !r.getName().startsWith(RefNames.REFS_TAGS)
                       && !r.isSymbolic()
                       && !r.getName().equals(RefNames.REFS_CONFIG))
-          // Don't use the default Java Collections.toList() as that is not size-aware and would
-          // expand an array list as new elements are added. Instead, provide a list that has the
-          // right size. This spares incremental list expansion which is quadratic in complexity.
-          .collect(toCollection(() -> new ArrayList<>(allRefs.size())));
+          .collect(Collectors.toList());
     } catch (IOException e) {
       throw new PermissionBackendException(e);
     }
@@ -305,27 +295,12 @@
     if (!canReadRef(REFS_CONFIG)) {
       return refs.stream()
           .filter(r -> !r.getName().equals(REFS_CONFIG))
-          // Don't use the default Java Collections.toList() as that is not size-aware and would
-          // expand an array list as new elements are added. Instead, provide a list that has the
-          // right size. This spares incremental list expansion which is quadratic in complexity.
           .collect(toCollection(() -> new ArrayList<>(refs.size())));
     }
     return refs;
   }
 
-  private boolean visible(Repository repo, Change.Id changeId) throws PermissionBackendException {
-    if (visibleChanges == null) {
-      if (changeCache == null) {
-        visibleChanges = visibleChangesByScan(repo);
-      } else {
-        visibleChanges = visibleChangesBySearch();
-      }
-      logger.atFinest().log("Visible changes: %s", visibleChanges.keySet());
-    }
-    return visibleChanges.containsKey(changeId);
-  }
-
-  private boolean visibleEdit(Repository repo, String name) throws PermissionBackendException {
+  private boolean visibleEdit(String name) throws PermissionBackendException {
     Change.Id id = Change.Id.fromEditRefPart(name);
     if (id == null) {
       logger.atWarning().log("Couldn't extract change ID from edit ref %s", name);
@@ -334,20 +309,16 @@
 
     if (user.isIdentifiedUser()
         && name.startsWith(RefNames.refsEditPrefix(user.asIdentifiedUser().getAccountId()))
-        && visible(repo, id)) {
+        && visibleChangesCache.isVisible(id)) {
       logger.atFinest().log("Own change edit ref is visible: %s", name);
       return true;
     }
 
-    // Initialize visibleChanges if it wasn't initialized yet.
-    if (visibleChanges == null) {
-      visible(repo, id);
-    }
-    if (visibleChanges.containsKey(id)) {
+    if (visibleChangesCache.isVisible(id)) {
       try {
         // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
         permissionBackendForProject
-            .ref(visibleChanges.get(id).branch())
+            .ref(visibleChangesCache.getBranchNameKey(id).branch())
             .check(RefPermission.READ_PRIVATE_CHANGES);
         logger.atFinest().log("Foreign change edit ref is visible: %s", name);
         return true;
@@ -361,72 +332,6 @@
     return false;
   }
 
-  private Map<Change.Id, BranchNameKey> visibleChangesBySearch() throws PermissionBackendException {
-    Project.NameKey project = projectState.getNameKey();
-    try {
-      Map<Change.Id, BranchNameKey> visibleChanges = new HashMap<>();
-      for (ChangeData cd : changeCache.getChangeData(project)) {
-        if (!projectState.statePermitsRead()) {
-          continue;
-        }
-        try {
-          permissionBackendForProject.change(cd).check(ChangePermission.READ);
-          visibleChanges.put(cd.getId(), cd.change().getDest());
-        } catch (AuthException e) {
-          // Do nothing.
-        }
-      }
-      return visibleChanges;
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot load changes for project %s, assuming no changes are visible", project);
-      return Collections.emptyMap();
-    }
-  }
-
-  private Map<Change.Id, BranchNameKey> visibleChangesByScan(Repository repo)
-      throws PermissionBackendException {
-    Project.NameKey p = projectState.getNameKey();
-    ImmutableList<ChangeNotesResult> changes;
-    try {
-      changes = changeNotesFactory.scan(repo, p).collect(toImmutableList());
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot load changes for project %s, assuming no changes are visible", p);
-      return Collections.emptyMap();
-    }
-
-    Map<Change.Id, BranchNameKey> result = Maps.newHashMapWithExpectedSize(changes.size());
-    for (ChangeNotesResult notesResult : changes) {
-      ChangeNotes notes = toNotes(notesResult);
-      if (notes != null) {
-        result.put(notes.getChangeId(), notes.getChange().getDest());
-      }
-    }
-    return result;
-  }
-
-  @Nullable
-  private ChangeNotes toNotes(ChangeNotesResult r) throws PermissionBackendException {
-    if (r.error().isPresent()) {
-      logger.atWarning().withCause(r.error().get()).log(
-          "Failed to load change %s in %s", r.id(), projectState.getName());
-      return null;
-    }
-
-    if (!projectState.statePermitsRead()) {
-      return null;
-    }
-
-    try {
-      permissionBackendForProject.change(r.notes()).check(ChangePermission.READ);
-      return r.notes();
-    } catch (AuthException e) {
-      // Skip.
-    }
-    return null;
-  }
-
   private boolean isMetadata(String name) {
     boolean isMetaData = RefNames.isRefsChanges(name) || RefNames.isRefsEdit(name);
     logger.atFinest().log("ref %s is " + (isMetaData ? "" : "not ") + "a metadata ref", name);
diff --git a/java/com/google/gerrit/server/permissions/GlobalPermission.java b/java/com/google/gerrit/server/permissions/GlobalPermission.java
index 07c9e84..d4f22e6 100644
--- a/java/com/google/gerrit/server/permissions/GlobalPermission.java
+++ b/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -18,6 +18,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.CapabilityScope;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -31,7 +32,12 @@
 import java.util.Optional;
 import java.util.Set;
 
-/** Global server permissions built into Gerrit. */
+/**
+ * Global server permissions built into Gerrit.
+ *
+ * <p>See also {@link GlobalCapability} which lists the equivalent strings used in the
+ * refs/meta/config settings in All-Projects.
+ */
 public enum GlobalPermission implements GlobalOrPluginPermission {
   ACCESS_DATABASE,
   ADMINISTRATE_SERVER,
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index eceb970..27c6793 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -60,9 +60,9 @@
  * <p>{@code PermissionBackend} is a singleton for the server, acting as a factory for lightweight
  * request instances. Implementation classes may cache supporting data inside of {@link WithUser},
  * {@link ForProject}, {@link ForRef}, and {@link ForChange} instances, in addition to storing
- * within {@link CurrentUser} using a {@link com.google.gerrit.server.CurrentUser.PropertyKey}.
- * {@link GlobalPermission} caching for {@link WithUser} may best cached inside {@link CurrentUser}
- * as {@link WithUser} instances are frequently created.
+ * within {@link CurrentUser} using a {@link com.google.gerrit.server.PropertyMap.Key}. {@link
+ * GlobalPermission} caching for {@link WithUser} may best cached inside {@link CurrentUser} as
+ * {@link WithUser} instances are frequently created.
  *
  * <p>Example use:
  *
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index fd82559..dd00dca 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.CallerFinder;
+import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
@@ -425,40 +426,52 @@
   /** True if the user has this permission. */
   private boolean canPerform(String permissionName, boolean isChangeOwner, boolean withForce) {
     if (isBlocked(permissionName, isChangeOwner, withForce)) {
-      logger.atFine().log(
-          "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
-              + " because this permission is blocked (caller: %s)",
-          getUser().getLoggableName(),
-          permissionName,
-          withForce,
-          projectControl.getProject().getName(),
-          refName,
-          callerFinder.findCallerLazy());
+      if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+        String logMessage =
+            String.format(
+                "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
+                    + " because this permission is blocked",
+                getUser().getLoggableName(),
+                permissionName,
+                withForce,
+                projectControl.getProject().getName(),
+                refName);
+        LoggingContext.getInstance().addAclLogRecord(logMessage);
+        logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+      }
       return false;
     }
 
     for (PermissionRule pr : relevant.getAllowRules(permissionName)) {
       if (isAllow(pr, withForce) && projectControl.match(pr, isChangeOwner)) {
-        logger.atFine().log(
-            "'%s' can perform '%s' with force=%s on project '%s' for ref '%s' (caller: %s)",
-            getUser().getLoggableName(),
-            permissionName,
-            withForce,
-            projectControl.getProject().getName(),
-            refName,
-            callerFinder.findCallerLazy());
+        if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+          String logMessage =
+              String.format(
+                  "'%s' can perform '%s' with force=%s on project '%s' for ref '%s'",
+                  getUser().getLoggableName(),
+                  permissionName,
+                  withForce,
+                  projectControl.getProject().getName(),
+                  refName);
+          LoggingContext.getInstance().addAclLogRecord(logMessage);
+          logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+        }
         return true;
       }
     }
 
-    logger.atFine().log(
-        "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s' (caller: %s)",
-        getUser().getLoggableName(),
-        permissionName,
-        withForce,
-        projectControl.getProject().getName(),
-        refName,
-        callerFinder.findCallerLazy());
+    if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+      String logMessage =
+          String.format(
+              "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'",
+              getUser().getLoggableName(),
+              permissionName,
+              withForce,
+              projectControl.getProject().getName(),
+              refName);
+      LoggingContext.getInstance().addAclLogRecord(logMessage);
+      logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+    }
     return false;
   }
 
diff --git a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
index 4744037..cc6387b 100644
--- a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
+++ b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
@@ -118,9 +118,14 @@
     Account.Id accountId;
     if ((accountId = Account.Id.fromRef(refName)) != null) {
       // Account ref is visible only to the corresponding account.
-      if (accountId.equals(currentUserAccountId)
-          && projectControl.controlForRef(refName).hasReadPermissionOnRef(true)) {
-        return true;
+      if (accountId.equals(currentUserAccountId)) {
+        // Always allow visibility to refs/draft-comments and refs/starred-changes. For all other
+        // refs, check if the user has read permissions.
+        if (RefNames.isRefsDraftsComments(refName)
+            || RefNames.isRefsStarredChanges(refName)
+            || projectControl.controlForRef(refName).hasReadPermissionOnRef(true)) {
+          return true;
+        }
       }
       return false;
     }
diff --git a/java/com/google/gerrit/server/permissions/SectionSortCache.java b/java/com/google/gerrit/server/permissions/SectionSortCache.java
index 6081e9a..d800782 100644
--- a/java/com/google/gerrit/server/permissions/SectionSortCache.java
+++ b/java/com/google/gerrit/server/permissions/SectionSortCache.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.permissions;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
@@ -28,6 +30,8 @@
 import java.util.ArrayList;
 import java.util.IdentityHashMap;
 import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
 
 /**
  * Caches the order AccessSections should be sorted for evaluation.
@@ -60,67 +64,62 @@
     this.cache = cache;
   }
 
-  // Sorts the given sections, but does not disturb ordering between equally exact sections.
+  /**
+   * Sorts the given sections in-place, but does not disturb ordering between equally exact
+   * sections.
+   */
   void sort(String ref, List<AccessSection> sections) {
     final int cnt = sections.size();
     if (cnt <= 1) {
       return;
     }
-
     EntryKey key = EntryKey.create(ref, sections);
-    EntryVal val = cache.getIfPresent(key);
-    if (val != null) {
-      int[] srcIdx = val.order;
-      if (srcIdx != null) {
-        AccessSection[] srcList = copy(sections);
-        for (int i = 0; i < cnt; i++) {
-          sections.set(i, srcList[srcIdx[i]]);
-        }
-      } else {
-        // Identity transform. No sorting is required.
-      }
+    EntryVal val;
+    try {
+      val = cache.get(key, new Loader(key, sections));
+    } catch (ExecutionException e) {
+      logger.atWarning().withCause(e).log("Error happened while sorting access sections.");
+      return;
+    }
+    ImmutableList<Integer> order = val.order();
+    List<AccessSection> sorted = new ArrayList<>();
+    for (int i = 0; i < cnt; i++) {
+      sorted.add(sections.get(order.get(i)));
+    }
+    for (int i = 0; i < cnt; i++) {
+      sections.set(i, sorted.get(i));
+    }
+  }
 
-    } else {
-      boolean poison = false;
+  private static class Loader implements Callable<EntryVal> {
+    private final List<AccessSection> sections;
+    EntryKey key;
+
+    Loader(EntryKey key, List<AccessSection> sections) {
+      this.key = key;
+      this.sections = sections;
+    }
+
+    @Override
+    public EntryVal call() throws Exception {
+      // We use IdentityHashMap (which uses reference equality for keys/values) to preserve distinct
+      // entries in the map for identical AccessSection keys
       IdentityHashMap<AccessSection, Integer> srcMap = new IdentityHashMap<>();
-      for (int i = 0; i < cnt; i++) {
-        poison |= srcMap.put(sections.get(i), i) != null;
+      for (int i = 0; i < sections.size(); i++) {
+        srcMap.put(sections.get(i), i);
       }
-
-      sections.sort(new MostSpecificComparator(ref));
-
-      int[] srcIdx;
-      if (isIdentityTransform(sections, srcMap)) {
-        srcIdx = null;
-      } else {
-        srcIdx = new int[cnt];
-        for (int i = 0; i < cnt; i++) {
-          srcIdx[i] = srcMap.get(sections.get(i));
-        }
+      ImmutableList<AccessSection> sorted =
+          sections.stream()
+              .sorted(new MostSpecificComparator(key.ref()))
+              .collect(toImmutableList());
+      ImmutableList.Builder<Integer> order = ImmutableList.builderWithExpectedSize(sections.size());
+      for (int i = 0; i < sorted.size(); i++) {
+        order.add(srcMap.get(sorted.get(i)));
       }
-
-      if (poison) {
-        logger.atSevere().log("Received duplicate AccessSection instances, not caching sort");
-      } else {
-        cache.put(key, new EntryVal(srcIdx));
-      }
+      return EntryVal.create(order.build());
     }
   }
 
-  private static AccessSection[] copy(List<AccessSection> sections) {
-    return sections.toArray(new AccessSection[sections.size()]);
-  }
-
-  private static boolean isIdentityTransform(
-      List<AccessSection> sections, IdentityHashMap<AccessSection, Integer> srcMap) {
-    for (int i = 0; i < sections.size(); i++) {
-      if (i != srcMap.get(sections.get(i))) {
-        return false;
-      }
-    }
-    return true;
-  }
-
   @AutoValue
   abstract static class EntryKey {
     public abstract String ref();
@@ -146,17 +145,18 @@
     }
   }
 
-  static final class EntryVal {
+  @AutoValue
+  abstract static class EntryVal {
     /**
      * Maps the input index to the output index.
      *
      * <p>For {@code x == order[y]} the expression means move the item at source position {@code x}
      * to the output position {@code y}.
      */
-    final int[] order;
+    abstract ImmutableList<Integer> order();
 
-    EntryVal(int[] order) {
-      this.order = order;
+    static EntryVal create(ImmutableList<Integer> order) {
+      return new AutoValue_SectionSortCache_EntryVal(order);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/VisibleChangesCache.java b/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
new file mode 100644
index 0000000..2e47576
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
@@ -0,0 +1,169 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Gets all of the visible by current user changes in the repository that are available in the
+ * change index and cache.
+ */
+class VisibleChangesCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  interface Factory {
+    VisibleChangesCache create(ProjectControl projectControl, Repository repository);
+  }
+
+  @Nullable private final SearchingChangeCacheImpl changeCache;
+  private final ProjectState projectState;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final PermissionBackend.ForProject permissionBackendForProject;
+
+  private final Repository repository;
+  private Map<Change.Id, BranchNameKey> visibleChanges;
+
+  @Inject
+  VisibleChangesCache(
+      @Nullable SearchingChangeCacheImpl changeCache,
+      PermissionBackend permissionBackend,
+      ChangeNotes.Factory changeNotesFactory,
+      @Assisted ProjectControl projectControl,
+      @Assisted Repository repository) {
+    this.changeCache = changeCache;
+    this.projectState = projectControl.getProjectState();
+    this.permissionBackendForProject =
+        permissionBackend.user(projectControl.getUser()).project(projectState.getNameKey());
+    this.changeNotesFactory = changeNotesFactory;
+    this.repository = repository;
+  }
+
+  /**
+   * Returns {@code true} if the {@code changeId} in repository {@code repo} is visible to the user,
+   * by looking at the cached visible changes.
+   */
+  public boolean isVisible(Change.Id changeId) throws PermissionBackendException {
+    cachedVisibleChanges();
+    return visibleChanges.containsKey(changeId);
+  }
+
+  /**
+   * Returns the visible changes in the repository {@code repo}. If not cached, computes the visible
+   * changes and caches them.
+   */
+  public Map<Change.Id, BranchNameKey> cachedVisibleChanges() throws PermissionBackendException {
+    if (visibleChanges == null) {
+      if (changeCache == null) {
+        visibleChangesByScan();
+      } else {
+        visibleChangesBySearch();
+      }
+      logger.atFinest().log("Visible changes: %s", visibleChanges.keySet());
+    }
+    return visibleChanges;
+  }
+
+  /**
+   * Returns the {@code BranchNameKey} for {@code changeId}. If not cached, computes *all* visible
+   * changes and caches them before returning this specific change. If not visible or not found,
+   * returns {@code null}.
+   */
+  @Nullable
+  public BranchNameKey getBranchNameKey(Change.Id changeId) throws PermissionBackendException {
+    return cachedVisibleChanges().get(changeId);
+  }
+
+  private void visibleChangesBySearch() throws PermissionBackendException {
+    visibleChanges = new HashMap<>();
+    Project.NameKey project = projectState.getNameKey();
+    try {
+      for (ChangeData cd : changeCache.getChangeData(project)) {
+        if (!projectState.statePermitsRead()) {
+          continue;
+        }
+        try {
+          permissionBackendForProject.change(cd).check(ChangePermission.READ);
+          visibleChanges.put(cd.getId(), cd.change().getDest());
+        } catch (AuthException e) {
+          // Do nothing.
+        }
+      }
+    } catch (StorageException e) {
+      logger.atSevere().withCause(e).log(
+          "Cannot load changes for project %s, assuming no changes are visible", project);
+    }
+  }
+
+  private void visibleChangesByScan() throws PermissionBackendException {
+    visibleChanges = new HashMap<>();
+    Project.NameKey p = projectState.getNameKey();
+    ImmutableList<ChangeNotesResult> changes;
+    try {
+      changes = changeNotesFactory.scan(repository, p).collect(toImmutableList());
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log(
+          "Cannot load changes for project %s, assuming no changes are visible", p);
+      return;
+    }
+
+    for (ChangeNotesResult notesResult : changes) {
+      ChangeNotes notes = toNotes(notesResult);
+      if (notes != null) {
+        visibleChanges.put(notes.getChangeId(), notes.getChange().getDest());
+      }
+    }
+  }
+
+  @Nullable
+  private ChangeNotes toNotes(ChangeNotesResult r) throws PermissionBackendException {
+    if (r.error().isPresent()) {
+      logger.atWarning().withCause(r.error().get()).log(
+          "Failed to load change %s in %s", r.id(), projectState.getName());
+      return null;
+    }
+
+    if (!projectState.statePermitsRead()) {
+      return null;
+    }
+
+    try {
+      permissionBackendForProject.change(r.notes()).check(ChangePermission.READ);
+      return r.notes();
+    } catch (AuthException e) {
+      // Skip.
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/plugins/JsPlugin.java b/java/com/google/gerrit/server/plugins/JsPlugin.java
index 12028b60..c120cdd 100644
--- a/java/com/google/gerrit/server/plugins/JsPlugin.java
+++ b/java/com/google/gerrit/server/plugins/JsPlugin.java
@@ -40,8 +40,7 @@
     String fileName = getSrcFile().getFileName().toString();
     int firstDash = fileName.indexOf("-");
     if (firstDash > 0) {
-      int extension =
-          fileName.endsWith(".js") ? fileName.lastIndexOf(".js") : fileName.lastIndexOf(".html");
+      int extension = fileName.lastIndexOf(".js");
       if (extension > 0) {
         return fileName.substring(firstDash + 1, extension);
       }
diff --git a/java/com/google/gerrit/server/plugins/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java
index c4f4a1f..0a06081 100644
--- a/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -733,7 +733,7 @@
   }
 
   private boolean isUiPlugin(String name) {
-    return isPlugin(name, "js") || isPlugin(name, "html");
+    return isPlugin(name, "js");
   }
 
   private boolean isPlugin(String fileName, String ext) {
diff --git a/java/com/google/gerrit/server/project/CreateRefControl.java b/java/com/google/gerrit/server/project/CreateRefControl.java
index 69ac93e..2e76949 100644
--- a/java/com/google/gerrit/server/project/CreateRefControl.java
+++ b/java/com/google/gerrit/server/project/CreateRefControl.java
@@ -105,7 +105,7 @@
       // If the tag has a PGP signature, allow a lower level of permission
       // than if it doesn't have a PGP signature.
       PermissionBackend.ForRef forRef = permissionBackend.user(user.get()).ref(branch);
-      if (tag.getFullMessage().contains("-----BEGIN PGP SIGNATURE-----\n")) {
+      if (tag.getRawGpgSignature() != null) {
         forRef.check(RefPermission.CREATE_SIGNED_TAG);
       } else {
         forRef.check(RefPermission.CREATE_TAG);
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 7aa4029..730162f 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -35,6 +35,8 @@
     label.copyAnyScore = toBoolean(labelType.isCopyAnyScore());
     label.copyMinScore = toBoolean(labelType.isCopyMinScore());
     label.copyMaxScore = toBoolean(labelType.isCopyMaxScore());
+    label.copyAllScoresIfListOfFilesDidNotChange =
+        toBoolean(labelType.isCopyAllScoresIfListOfFilesDidNotChange());
     label.copyAllScoresIfNoChange = toBoolean(labelType.isCopyAllScoresIfNoChange());
     label.copyAllScoresIfNoCodeChange = toBoolean(labelType.isCopyAllScoresIfNoCodeChange());
     label.copyAllScoresOnTrivialRebase = toBoolean(labelType.isCopyAllScoresOnTrivialRebase());
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 89038e2..4a063a3 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -111,6 +111,8 @@
   public static final String KEY_IGNORE_SELF_APPROVAL = "ignoreSelfApproval";
   public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
   public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
+  public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE =
+      "copyAllScoresIfListOfFilesDidNotChange";
   public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
       "copyAllScoresOnMergeFirstParentUpdate";
   public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
@@ -385,6 +387,7 @@
     this.accountsSection = accountsSection;
   }
 
+  /** Returns an access section, {@code name} typically is a ref pattern. */
   public AccessSection getAccessSection(String name) {
     return accessSections.get(name);
   }
@@ -1028,6 +1031,12 @@
           rc.getBoolean(LABEL, name, KEY_COPY_MIN_SCORE, LabelType.DEF_COPY_MIN_SCORE));
       label.setCopyMaxScore(
           rc.getBoolean(LABEL, name, KEY_COPY_MAX_SCORE, LabelType.DEF_COPY_MAX_SCORE));
+      label.setCopyAllScoresIfListOfFilesDidNotChange(
+          rc.getBoolean(
+              LABEL,
+              name,
+              KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+              LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE));
       label.setCopyAllScoresOnMergeFirstParentUpdate(
           rc.getBoolean(
               LABEL,
@@ -1565,6 +1574,13 @@
           rc,
           LABEL,
           name,
+          KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+          label.isCopyAllScoresIfListOfFilesDidNotChange(),
+          LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
+          name,
           KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
           label.isCopyAllScoresOnMergeFirstParentUpdate(),
           LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
diff --git a/java/com/google/gerrit/server/project/ProjectLevelConfig.java b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
index 4825233..8256198 100644
--- a/java/com/google/gerrit/server/project/ProjectLevelConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
@@ -16,14 +16,15 @@
 
 import static java.util.stream.Collectors.toList;
 
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
+import com.google.gerrit.entities.ImmutableConfig;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.Set;
 import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
 import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -73,23 +74,21 @@
 
   private final String fileName;
   private final ProjectState project;
-  private Config cfg;
+  private final ImmutableConfig immutableConfig;
 
-  public ProjectLevelConfig(String fileName, ProjectState project, Config cfg) {
+  public ProjectLevelConfig(
+      String fileName, ProjectState project, @Nullable ImmutableConfig immutableConfig) {
     this.fileName = fileName;
     this.project = project;
-    this.cfg = cfg;
+    this.immutableConfig = immutableConfig == null ? ImmutableConfig.EMPTY : immutableConfig;
   }
 
   public Config get() {
-    if (cfg == null) {
-      cfg = new Config();
-    }
-    return cfg;
+    return immutableConfig.mutableCopy();
   }
 
   public Config getWithInheritance() {
-    return getWithInheritance(false);
+    return getWithInheritance(/* merge= */ false);
   }
 
   /**
@@ -105,58 +104,61 @@
    * @return a combined config.
    */
   public Config getWithInheritance(boolean merge) {
-    Config cfgWithInheritance = new Config();
-    try {
-      cfgWithInheritance.fromText(get().toText());
-    } catch (ConfigInvalidException e) {
-      // cannot happen
-    }
-    ProjectState parent = Iterables.getFirst(project.parents(), null);
-    if (parent != null) {
-      Config parentCfg = parent.getConfig(fileName).getWithInheritance();
-      for (String section : parentCfg.getSections()) {
-        Set<String> allNames = get().getNames(section);
-        for (String name : parentCfg.getNames(section)) {
-          String[] parentValues = parentCfg.getStringList(section, null, name);
-          if (!allNames.contains(name)) {
-            cfgWithInheritance.setStringList(section, null, name, Arrays.asList(parentValues));
-          } else if (merge) {
-            cfgWithInheritance.setStringList(
-                section,
-                null,
-                name,
-                Stream.concat(
-                        Arrays.stream(cfg.getStringList(section, null, name)),
-                        Arrays.stream(parentValues))
-                    .sorted()
-                    .distinct()
-                    .collect(toList()));
-          }
-        }
+    Config cfg = new Config();
+    // Traverse from All-Projects down to the current project
+    StreamSupport.stream(project.treeInOrder().spliterator(), false)
+        .forEach(
+            parent -> {
+              ImmutableConfig levelCfg = parent.getConfig(fileName).immutableConfig;
+              for (String section : levelCfg.getSections()) {
+                Set<String> allNames = cfg.getNames(section);
+                for (String name : levelCfg.getNames(section)) {
+                  String[] levelValues = levelCfg.getStringList(section, null, name);
+                  if (allNames.contains(name) && merge) {
+                    cfg.setStringList(
+                        section,
+                        null,
+                        name,
+                        Stream.concat(
+                                Arrays.stream(cfg.getStringList(section, null, name)),
+                                Arrays.stream(levelValues))
+                            .sorted()
+                            .distinct()
+                            .collect(toList()));
+                  } else {
+                    cfg.setStringList(section, null, name, Arrays.asList(levelValues));
+                  }
+                }
 
-        for (String subsection : parentCfg.getSubsections(section)) {
-          allNames = get().getNames(section, subsection);
-          for (String name : parentCfg.getNames(section, subsection)) {
-            String[] parentValues = parentCfg.getStringList(section, subsection, name);
-            if (!allNames.contains(name)) {
-              cfgWithInheritance.setStringList(
-                  section, subsection, name, Arrays.asList(parentValues));
-            } else if (merge) {
-              cfgWithInheritance.setStringList(
-                  section,
-                  subsection,
-                  name,
-                  Streams.concat(
-                          Arrays.stream(cfg.getStringList(section, subsection, name)),
-                          Arrays.stream(parentValues))
-                      .sorted()
-                      .distinct()
-                      .collect(toList()));
-            }
-          }
-        }
-      }
-    }
-    return cfgWithInheritance;
+                for (String subsection : levelCfg.getSubsections(section)) {
+                  allNames = cfg.getNames(section, subsection);
+
+                  Set<String> allNamesLevelCfg = levelCfg.getNames(section, subsection);
+                  if (allNamesLevelCfg.isEmpty()) {
+                    // Set empty subsection.
+                    cfg.setString(section, subsection, null, null);
+                  } else {
+                    for (String name : allNamesLevelCfg) {
+                      String[] levelValues = levelCfg.getStringList(section, subsection, name);
+                      if (allNames.contains(name) && merge) {
+                        cfg.setStringList(
+                            section,
+                            subsection,
+                            name,
+                            Streams.concat(
+                                    Arrays.stream(cfg.getStringList(section, subsection, name)),
+                                    Arrays.stream(levelValues))
+                                .sorted()
+                                .distinct()
+                                .collect(toList()));
+                      } else {
+                        cfg.setStringList(section, subsection, name, Arrays.asList(levelValues));
+                      }
+                    }
+                  }
+                }
+              }
+            });
+    return cfg;
   }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index eecf1fe..249eb35 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -14,7 +14,10 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.PermissionRule.Action.ALLOW;
+import static java.util.Comparator.comparing;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.FluentIterable;
@@ -174,8 +177,9 @@
   }
 
   public ProjectLevelConfig getConfig(String fileName) {
-    Optional<Config> rawConfig = cachedConfig.getProjectLevelConfig(fileName);
-    return new ProjectLevelConfig(fileName, this, rawConfig.orElse(new Config()));
+    checkState(fileName.endsWith(".config"), "file name must end in .config. is: " + fileName);
+    return new ProjectLevelConfig(
+        fileName, this, cachedConfig.getParsedProjectLevelConfigs().get(fileName));
   }
 
   public long getMaxObjectSizeLimit() {
@@ -264,7 +268,10 @@
   List<SectionMatcher> getLocalAccessSections() {
     List<SectionMatcher> sm = localAccessSections;
     if (sm == null) {
-      Collection<AccessSection> fromConfig = cachedConfig.getAccessSections().values();
+      ImmutableList<AccessSection> fromConfig =
+          cachedConfig.getAccessSections().values().stream()
+              .sorted(comparing(AccessSection::getName))
+              .collect(toImmutableList());
       sm = new ArrayList<>(fromConfig.size());
       for (AccessSection section : fromConfig) {
         if (isAllProjects) {
diff --git a/java/com/google/gerrit/server/project/RefUtil.java b/java/com/google/gerrit/server/project/RefUtil.java
index dc8cdc7..797756b 100644
--- a/java/com/google/gerrit/server/project/RefUtil.java
+++ b/java/com/google/gerrit/server/project/RefUtil.java
@@ -102,6 +102,7 @@
     return Constants.R_HEADS;
   }
 
+  /** Fully qualifies a tag name to refs/tags/TAG-NAME */
   public static String normalizeTagRef(String tag) throws BadRequestException {
     String result = tag;
     while (result.startsWith("/")) {
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index 6bf3beb..652c49f 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -91,7 +91,7 @@
       Account.Id reviewer,
       int value)
       throws PermissionBackendException {
-    if (change.isMerged()) {
+    if (change.isMerged() && value != 0) {
       return false;
     }
 
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index 0e50bb0..a7659d4 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 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;
@@ -33,20 +33,14 @@
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
-import java.util.stream.Collectors;
 
 /**
  * Evaluates a submit-like Prolog rule found in the rules.pl file of the current project and filters
  * 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 static final String DEFAULT_MSG = "Error evaluating project rules, check server log";
-
   private final ProjectCache projectCache;
   private final PrologRule prologRule;
   private final PluginSetContext<SubmitRule> submitRules;
@@ -85,21 +79,6 @@
     this.opts = options;
   }
 
-  public static SubmitRecord defaultRuleError() {
-    return createRuleError(DEFAULT_MSG);
-  }
-
-  public static SubmitRecord createRuleError(String err) {
-    SubmitRecord rec = new SubmitRecord();
-    rec.status = SubmitRecord.Status.RULE_ERROR;
-    rec.errorMessage = err;
-    return rec;
-  }
-
-  public static SubmitTypeRecord defaultTypeError() {
-    return SubmitTypeRecord.error(DEFAULT_MSG);
-  }
-
   /**
    * Evaluate the submit rules.
    *
@@ -117,14 +96,22 @@
         }
 
         projectCache.get(cd.project()).orElseThrow(noSuchProject(cd.project()));
-      } catch (StorageException | NoSuchProjectException e) {
-        return Collections.singletonList(ruleError("Error looking up change " + cd.getId(), e));
+      } catch (NoSuchProjectException e) {
+        throw new IllegalStateException("Unable to find project while evaluating submit rule", e);
       }
 
-      if ((!opts.allowClosed() || OnlineReindexMode.isActive()) && change.isClosed()) {
-        SubmitRecord rec = new SubmitRecord();
-        rec.status = SubmitRecord.Status.CLOSED;
-        return Collections.singletonList(rec);
+      if (change.isClosed() && (!opts.recomputeOnClosedChanges() || OnlineReindexMode.isActive())) {
+        return cd.notes().getSubmitRecords().stream()
+            .map(
+                r -> {
+                  SubmitRecord record = r.deepCopy();
+                  if (record.status == SubmitRecord.Status.OK) {
+                    // Submit records that were OK when they got merged are CLOSED now.
+                    record.status = SubmitRecord.Status.CLOSED;
+                  }
+                  return record;
+                })
+            .collect(toImmutableList());
       }
 
       // We evaluate all the plugin-defined evaluators,
@@ -133,15 +120,10 @@
           .map(c -> c.call(s -> s.evaluate(cd)))
           .filter(Optional::isPresent)
           .map(Optional::get)
-          .collect(Collectors.toList());
+          .collect(toImmutableList());
     }
   }
 
-  private SubmitRecord ruleError(String err, Exception e) {
-    logger.atSevere().withCause(e).log(err);
-    return defaultRuleError();
-  }
-
   /**
    * Evaluate the submit type rules to get the submit type.
    *
@@ -153,15 +135,10 @@
       try {
         projectCache.get(cd.project()).orElseThrow(noSuchProject(cd.project()));
       } catch (NoSuchProjectException e) {
-        return typeError("Error looking up change " + cd.getId(), e);
+        throw new IllegalStateException("Unable to find project while evaluating submit rule", e);
       }
 
       return prologRule.getSubmitType(cd);
     }
   }
-
-  private SubmitTypeRecord typeError(String err, Exception e) {
-    logger.atSevere().withCause(e).log(err);
-    return defaultTypeError();
-  }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRuleOptions.java b/java/com/google/gerrit/server/project/SubmitRuleOptions.java
index ad077c0..3b511e1 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleOptions.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleOptions.java
@@ -25,7 +25,7 @@
 @AutoValue
 public abstract class SubmitRuleOptions {
   private static final SubmitRuleOptions defaults =
-      new AutoValue_SubmitRuleOptions.Builder().allowClosed(false).build();
+      new AutoValue_SubmitRuleOptions.Builder().recomputeOnClosedChanges(false).build();
 
   public static SubmitRuleOptions defaults() {
     return defaults;
@@ -35,13 +35,16 @@
     return defaults.toBuilder();
   }
 
-  public abstract boolean allowClosed();
+  /**
+   * True if the submit rules should be recomputed even when the change is already closed (merged).
+   */
+  public abstract boolean recomputeOnClosedChanges();
 
   public abstract Builder toBuilder();
 
   @AutoValue.Builder
   public abstract static class Builder {
-    public abstract SubmitRuleOptions.Builder allowClosed(boolean allowClosed);
+    public abstract SubmitRuleOptions.Builder recomputeOnClosedChanges(boolean allowClosed);
 
     public abstract SubmitRuleOptions build();
   }
diff --git a/java/com/google/gerrit/server/project/testing/BUILD b/java/com/google/gerrit/server/project/testing/BUILD
index 3112b5a..ab75ec7 100644
--- a/java/com/google/gerrit/server/project/testing/BUILD
+++ b/java/com/google/gerrit/server/project/testing/BUILD
@@ -5,8 +5,5 @@
     testonly = True,
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
-    deps = [
-        "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/entities",
-    ],
+    deps = ["//java/com/google/gerrit/entities"],
 )
diff --git a/java/com/google/gerrit/server/project/testing/TestLabels.java b/java/com/google/gerrit/server/project/testing/TestLabels.java
index 157c746..62f8560 100644
--- a/java/com/google/gerrit/server/project/testing/TestLabels.java
+++ b/java/com/google/gerrit/server/project/testing/TestLabels.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.project.testing;
 
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
 import java.util.Arrays;
@@ -22,7 +23,7 @@
 public class TestLabels {
   public static LabelType codeReview() {
     return label(
-        "Code-Review",
+        LabelId.CODE_REVIEW,
         value(2, "Looks good to me, approved"),
         value(1, "Looks good to me, but someone else must approve"),
         value(0, "No score"),
@@ -31,7 +32,8 @@
   }
 
   public static LabelType verified() {
-    return label("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
+    return label(
+        LabelId.VERIFIED, value(1, LabelId.VERIFIED), value(0, "No score"), value(-1, "Fails"));
   }
 
   public static LabelType patchSetLock() {
diff --git a/java/com/google/gerrit/server/query/change/AfterPredicate.java b/java/com/google/gerrit/server/query/change/AfterPredicate.java
index df5a71d..8f92d9a 100644
--- a/java/com/google/gerrit/server/query/change/AfterPredicate.java
+++ b/java/com/google/gerrit/server/query/change/AfterPredicate.java
@@ -14,15 +14,21 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeField;
+import java.sql.Timestamp;
 import java.util.Date;
 
+/**
+ * Predicate that matches a {@link Timestamp} field from the index in a range from the passed {@code
+ * String} representation of the Timestamp value to the maximum supported time.
+ */
 public class AfterPredicate extends TimestampRangeChangePredicate {
   protected final Date cut;
 
-  public AfterPredicate(String value) throws QueryParseException {
-    super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_BEFORE, value);
+  public AfterPredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
+      throws QueryParseException {
+    super(def, name, value);
     cut = parse(value);
   }
 
@@ -38,6 +44,10 @@
 
   @Override
   public boolean match(ChangeData cd) {
-    return cd.change().getLastUpdatedOn().getTime() >= cut.getTime();
+    Timestamp valueTimestamp = this.getValueTimestamp(cd);
+    if (valueTimestamp == null) {
+      return false;
+    }
+    return valueTimestamp.getTime() >= cut.getTime();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/AgePredicate.java b/java/com/google/gerrit/server/query/change/AgePredicate.java
index 36eb5b7..d38789f 100644
--- a/java/com/google/gerrit/server/query/change/AgePredicate.java
+++ b/java/com/google/gerrit/server/query/change/AgePredicate.java
@@ -17,7 +17,6 @@
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -46,7 +45,10 @@
 
   @Override
   public boolean match(ChangeData object) {
-    Change change = object.change();
-    return change != null && change.getLastUpdatedOn().getTime() <= cut;
+    Timestamp valueTimestamp = this.getValueTimestamp(object);
+    if (valueTimestamp == null) {
+      return false;
+    }
+    return valueTimestamp.getTime() <= cut;
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/BeforePredicate.java b/java/com/google/gerrit/server/query/change/BeforePredicate.java
index dacabc0..6e28ce6 100644
--- a/java/com/google/gerrit/server/query/change/BeforePredicate.java
+++ b/java/com/google/gerrit/server/query/change/BeforePredicate.java
@@ -14,15 +14,21 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeField;
+import java.sql.Timestamp;
 import java.util.Date;
 
+/**
+ * Predicate that matches a {@link Timestamp} field from the index in a range from the the epoch to
+ * the passed {@code String} representation of the Timestamp value.
+ */
 public class BeforePredicate extends TimestampRangeChangePredicate {
   protected final Date cut;
 
-  public BeforePredicate(String value) throws QueryParseException {
-    super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_BEFORE, value);
+  public BeforePredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
+      throws QueryParseException {
+    super(def, name, value);
     cut = parse(value);
   }
 
@@ -38,6 +44,10 @@
 
   @Override
   public boolean match(ChangeData cd) {
-    return cd.change().getLastUpdatedOn().getTime() <= cut.getTime();
+    Timestamp valueTimestamp = this.getValueTimestamp(cd);
+    if (valueTimestamp == null) {
+      return false;
+    }
+    return valueTimestamp.getTime() <= cut.getTime();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 2c43bb7..f7167cd 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -26,13 +26,17 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
+import com.google.common.collect.SetMultimap;
+import com.google.common.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;
@@ -43,6 +47,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmitRecord;
@@ -50,6 +55,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -69,6 +75,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.RobotCommentNotes;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.PatchListCache;
@@ -103,7 +110,31 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/**
+ * ChangeData provides lazily loaded interface to change metadata loaded from NoteDb. It can be
+ * constructed by loading from NoteDb, or calling setters. The latter happens when ChangeData is
+ * retrieved through the change index. This happens for Applications that are performance sensitive
+ * (eg. dashboard loads, git protocol negotiation) but can tolerate staleness. In that case, setting
+ * lazyLoad=false disables loading from NoteDb, so we don't accidentally enable a slow path.
+ */
 public class ChangeData {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public enum StorageConstraint {
+    /**
+     * This instance was loaded from the change index. Backfilling missing data from NoteDb is not
+     * allowed.
+     */
+    INDEX_ONLY,
+    /**
+     * This instance was loaded from the change index. Backfilling missing data from NoteDb is
+     * allowed.
+     */
+    INDEX_PRIMARY_NOTEDB_SECONDARY,
+    /** This instance was loaded from NoteDb. */
+    NOTEDB_ONLY
+  }
+
   public static List<Change> asChanges(List<ChangeData> changeDatas) {
     List<Change> result = new ArrayList<>(changeDatas.size());
     for (ChangeData cd : changeDatas) {
@@ -269,7 +300,7 @@
   private final Map<SubmitRuleOptions, List<SubmitRecord>> submitRecords =
       Maps.newLinkedHashMapWithExpectedSize(1);
 
-  private boolean lazyLoad = true;
+  private StorageConstraint storageConstraint = StorageConstraint.NOTEDB_ONLY;
   private Change change;
   private ChangeNotes notes;
   private String commitMessage;
@@ -307,8 +338,8 @@
   private Integer unresolvedCommentCount;
   private Integer totalCommentCount;
   private LabelTypes labelTypes;
-
-  private ImmutableList<byte[]> refStates;
+  private Optional<Timestamp> mergedOn;
+  private ImmutableSetMultimap<NameKey, RefState> refStates;
   private ImmutableList<byte[]> refStatePatterns;
 
   @Inject
@@ -362,11 +393,17 @@
    * lazyLoad} is on, the {@code ChangeData} object will load from the database ("lazily") when a
    * field accessor is called.
    */
-  public ChangeData setLazyLoad(boolean load) {
-    lazyLoad = load;
+  public ChangeData setStorageConstraint(StorageConstraint storageConstraint) {
+    this.storageConstraint = storageConstraint;
     return this;
   }
 
+  /** Returns {@code true} if we allow reading data from NoteDb. */
+  public boolean lazyload() {
+    return storageConstraint.ordinal()
+        >= StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY.ordinal();
+  }
+
   public AllUsersName getAllUsersNameForIndexing() {
     return allUsersName;
   }
@@ -381,7 +418,7 @@
 
   public List<String> currentFilePaths() {
     if (currentFiles == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptyList();
       }
       Optional<DiffSummary> p = getDiffSummary();
@@ -392,7 +429,7 @@
 
   private Optional<DiffSummary> getDiffSummary() {
     if (diffSummary == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Optional.empty();
       }
 
@@ -423,7 +460,7 @@
 
   public Optional<ChangedLines> changedLines() {
     if (changedLines == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Optional.empty();
       }
       changedLines = computeChangedLines();
@@ -456,7 +493,7 @@
   }
 
   public Change change() {
-    if (change == null && lazyLoad) {
+    if (change == null && lazyload()) {
       reloadChange();
     }
     return change;
@@ -487,7 +524,7 @@
 
   public ChangeNotes notes() {
     if (notes == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         throw new StorageException("ChangeNotes not available, lazyLoad = false");
       }
       notes = notesFactory.create(project(), legacyId);
@@ -513,7 +550,7 @@
 
   public List<PatchSetApproval> currentApprovals() {
     if (currentApprovals == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptyList();
       }
       Change c = change();
@@ -594,6 +631,7 @@
       author = c.getAuthorIdent();
       committer = c.getCommitterIdent();
       parentCount = c.getParentCount();
+      merge = parentCount > 1;
     } catch (IOException e) {
       throw new StorageException(
           String.format(
@@ -607,7 +645,7 @@
   /** Returns the most recent update (i.e. status) per user. */
   public ImmutableSet<AttentionSetUpdate> attentionSet() {
     if (attentionSet == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return ImmutableSet.of();
       }
       attentionSet = notes().getAttentionSet();
@@ -616,6 +654,29 @@
   }
 
   /**
+   * Returns the {@link Optional} value of time when the change was merged.
+   *
+   * <p>The value can be set from index field, see {@link ChangeData#setMergedOn} or loaded from the
+   * database (available in {@link ChangeNotes})
+   *
+   * @return {@link Optional} value of time when the change was merged.
+   * @throws StorageException if {@code lazyLoad} is off, {@link ChangeNotes} can not be loaded
+   *     because we do not expect to call the database.
+   */
+  public Optional<Timestamp> getMergedOn() throws StorageException {
+    if (mergedOn == null) {
+      // The value was not loaded yet, try to get from the database.
+      mergedOn = notes().getMergedOn();
+    }
+    return mergedOn;
+  }
+
+  /** Sets the value e.g. when loading from index. */
+  public void setMergedOn(@Nullable Timestamp mergedOn) {
+    this.mergedOn = Optional.ofNullable(mergedOn);
+  }
+
+  /**
    * Sets the specified attention set. If two or more entries refer to the same user, throws an
    * {@link IllegalStateException}.
    */
@@ -662,7 +723,7 @@
    */
   public ListMultimap<PatchSet.Id, PatchSetApproval> approvals() {
     if (allApprovals == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return ImmutableListMultimap.of();
       }
       allApprovals = approvalsUtil.byChange(notes());
@@ -670,14 +731,16 @@
     return allApprovals;
   }
 
-  /** @return The submit ('SUBM') approval label */
+  /* @return legacy submit ('SUBM') approval label */
+  // TODO(mariasavtchouk): Deprecate legacy submit label,
+  // see com.google.gerrit.entities.LabelId.LEGACY_SUBMIT_NAME
   public Optional<PatchSetApproval> getSubmitApproval() {
     return currentApprovals().stream().filter(PatchSetApproval::isLegacySubmit).findFirst();
   }
 
   public ReviewerSet reviewers() {
     if (reviewers == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         // We are not allowed to load values from NoteDb. Reviewers were not populated with values
         // from the index. However, we need these values for permission checks.
         throw new IllegalStateException("reviewers not populated");
@@ -693,7 +756,7 @@
 
   public ReviewerByEmailSet reviewersByEmail() {
     if (reviewersByEmail == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return ReviewerByEmailSet.empty();
       }
       reviewersByEmail = notes().getReviewersByEmail();
@@ -719,7 +782,7 @@
 
   public ReviewerSet pendingReviewers() {
     if (pendingReviewers == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return ReviewerSet.empty();
       }
       pendingReviewers = notes().getPendingReviewers();
@@ -737,7 +800,7 @@
 
   public ReviewerByEmailSet pendingReviewersByEmail() {
     if (pendingReviewersByEmail == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return ReviewerByEmailSet.empty();
       }
       pendingReviewersByEmail = notes().getPendingReviewersByEmail();
@@ -747,7 +810,7 @@
 
   public List<ReviewerStatusUpdate> reviewerUpdates() {
     if (reviewerUpdates == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptyList();
       }
       reviewerUpdates = approvalsUtil.getReviewerUpdates(notes());
@@ -765,7 +828,7 @@
 
   public Collection<HumanComment> publishedComments() {
     if (publishedComments == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptyList();
       }
       publishedComments = commentsUtil.publishedHumanCommentsByChange(notes());
@@ -775,7 +838,7 @@
 
   public Collection<RobotComment> robotComments() {
     if (robotComments == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptyList();
       }
       robotComments = commentsUtil.robotCommentsByChange(notes());
@@ -785,7 +848,7 @@
 
   public Integer unresolvedCommentCount() {
     if (unresolvedCommentCount == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return null;
       }
 
@@ -807,7 +870,7 @@
 
   public Integer totalCommentCount() {
     if (totalCommentCount == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return null;
       }
 
@@ -824,7 +887,7 @@
 
   public List<ChangeMessage> messages() {
     if (messages == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptyList();
       }
       messages = cmUtil.byChange(notes());
@@ -833,36 +896,35 @@
   }
 
   public List<SubmitRecord> submitRecords(SubmitRuleOptions options) {
-    List<SubmitRecord> records = getCachedSubmitRecord(options);
+    // If the change is not submitted yet, 'strict' and 'lenient' both have the same result. If the
+    // change is submitted, SubmitRecord requested with 'strict' will contain just a single entry
+    // that with status=CLOSED. The latter is cheap to evaluate as we don't have to run any actual
+    // evaluation.
+    List<SubmitRecord> records = submitRecords.get(options);
     if (records == null) {
-      if (!lazyLoad) {
+      if (storageConstraint != StorageConstraint.NOTEDB_ONLY) {
+        // Submit requirements are expensive. We allow loading them only if this change did not
+        // originate from the change index and we can invest the extra time.
+        logger.atWarning().log(
+            "Tried to load SubmitRecords for change fetched from index %s: %d",
+            project(), getId().get());
         return Collections.emptyList();
       }
       records = submitRuleEvaluatorFactory.create(options).evaluate(this);
       submitRecords.put(options, records);
+      if (!change().isClosed() && submitRecords.size() == 1) {
+        // Cache the SubmitRecord with allowClosed = !allowClosed as the SubmitRecord are the same.
+        submitRecords.put(
+            options
+                .toBuilder()
+                .recomputeOnClosedChanges(!options.recomputeOnClosedChanges())
+                .build(),
+            records);
+      }
     }
     return records;
   }
 
-  @Nullable
-  public List<SubmitRecord> getSubmitRecords(SubmitRuleOptions options) {
-    return getCachedSubmitRecord(options);
-  }
-
-  private List<SubmitRecord> getCachedSubmitRecord(SubmitRuleOptions options) {
-    List<SubmitRecord> records = submitRecords.get(options);
-    if (records != null) {
-      return records;
-    }
-
-    if (options.allowClosed() && change != null && change.getStatus().isOpen()) {
-      SubmitRuleOptions openSubmitRuleOptions = options.toBuilder().allowClosed(false).build();
-      return submitRecords.get(openSubmitRuleOptions);
-    }
-
-    return null;
-  }
-
   public void setSubmitRecords(SubmitRuleOptions options, List<SubmitRecord> records) {
     submitRecords.put(options, records);
   }
@@ -893,7 +955,7 @@
       } else if (c.isWorkInProgress()) {
         return null;
       } else {
-        if (!lazyLoad) {
+        if (!lazyload()) {
           return null;
         }
         PatchSet ps = currentPatchSet();
@@ -930,7 +992,7 @@
         return null;
       }
     }
-    return parentCount > 1;
+    return merge;
   }
 
   public Set<Account.Id> editsByUser() {
@@ -939,7 +1001,7 @@
 
   public Map<Account.Id, Ref> editRefs() {
     if (editsByUser == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptyMap();
       }
       Change c = change();
@@ -971,7 +1033,7 @@
 
   public Map<Account.Id, Ref> draftRefs() {
     if (draftsByUser == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptyMap();
       }
       Change c = change();
@@ -1016,7 +1078,7 @@
 
   public Set<Account.Id> reviewedBy() {
     if (reviewedBy == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptySet();
       }
       Change c = change();
@@ -1048,7 +1110,7 @@
 
   public Set<String> hashtags() {
     if (hashtags == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptySet();
       }
       hashtags = notes().getHashtags();
@@ -1062,7 +1124,7 @@
 
   public ImmutableListMultimap<Account.Id, String> stars() {
     if (stars == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return ImmutableListMultimap.of();
       }
       ImmutableListMultimap.Builder<Account.Id, String> b = ImmutableListMultimap.builder();
@@ -1080,7 +1142,7 @@
 
   public ImmutableMap<Account.Id, StarRef> starRefs() {
     if (starRefs == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return ImmutableMap.of();
       }
       starRefs = requireNonNull(starredChangesUtil).byChange(legacyId);
@@ -1098,7 +1160,7 @@
       if (stars != null) {
         starsOf = StarsOf.create(accountId, stars.get(accountId));
       } else {
-        if (!lazyLoad) {
+        if (!lazyload()) {
           return ImmutableSet.of();
         }
         starsOf = StarsOf.create(accountId, starredChangesUtil.getLabels(accountId, legacyId));
@@ -1144,12 +1206,38 @@
     }
   }
 
-  public ImmutableList<byte[]> getRefStates() {
+  public SetMultimap<NameKey, RefState> getRefStates() {
+    if (refStates == null) {
+      if (!lazyload()) {
+        return ImmutableSetMultimap.of();
+      }
+
+      ImmutableSetMultimap.Builder<NameKey, RefState> result = ImmutableSetMultimap.builder();
+      editRefs().values().forEach(r -> result.put(project, RefState.of(r)));
+      starRefs().values().forEach(r -> result.put(allUsersName, RefState.of(r.ref())));
+
+      // TODO: instantiating the notes is too much. We don't want to parse NoteDb, we just want the
+      // refs.
+      result.put(project, RefState.create(notes().getRefName(), notes().getMetaId()));
+      notes().getRobotComments(); // Force loading robot comments.
+      RobotCommentNotes robotNotes = notes().getRobotCommentNotes();
+      result.put(project, RefState.create(robotNotes.getRefName(), robotNotes.getMetaId()));
+      draftRefs().values().forEach(r -> result.put(allUsersName, RefState.of(r)));
+
+      refStates = result.build();
+    }
+
     return refStates;
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public void setRefStates(Iterable<byte[]> refStates) {
-    this.refStates = ImmutableList.copyOf(refStates);
+    // TODO(hanwen): remove Google use, and drop this method.
+    setRefStates(RefState.parseStates(refStates));
+  }
+
+  public void setRefStates(ImmutableSetMultimap<Project.NameKey, RefState> refStates) {
+    this.refStates = refStates;
   }
 
   public ImmutableList<byte[]> getRefStatePatterns() {
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index c6bcd60..a66c43ae 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -88,7 +88,7 @@
             ? permissionBackend.absentUser(user.getAccountId())
             : permissionBackend.user(
                 Optional.of(user)
-                    .filter(u -> u instanceof SingleGroupUser || u instanceof InternalUser)
+                    .filter(u -> u instanceof GroupBackedUser || u instanceof InternalUser)
                     .orElseGet(anonymousUserProvider::get));
     try {
       withUser.change(cd).check(ChangePermission.READ);
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 04e6d49..b02b52d 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -142,7 +142,7 @@
   public static final String FIELD_ASSIGNEE = "assignee";
   public static final String FIELD_AUTHOR = "author";
   public static final String FIELD_EXACTAUTHOR = "exactauthor";
-  public static final String FIELD_BEFORE = "before";
+
   public static final String FIELD_CHANGE = "change";
   public static final String FIELD_CHANGE_ID = "change_id";
   public static final String FIELD_COMMENT = "comment";
@@ -169,9 +169,11 @@
   public static final String FIELD_LIMIT = "limit";
   public static final String FIELD_MERGE = "merge";
   public static final String FIELD_MERGEABLE = "mergeable2";
+  public static final String FIELD_MERGED_ON = "mergedon";
   public static final String FIELD_MESSAGE = "message";
   public static final String FIELD_OWNER = "owner";
   public static final String FIELD_OWNERIN = "ownerin";
+  public static final String FIELD_PARENTOF = "parentof";
   public static final String FIELD_PARENTPROJECT = "parentproject";
   public static final String FIELD_PATH = "path";
   public static final String FIELD_PENDING_REVIEWER = "pendingreviewer";
@@ -199,11 +201,19 @@
   public static final String FIELD_CHERRY_PICK_OF_CHANGE = "cherrypickofchange";
   public static final String FIELD_CHERRY_PICK_OF_PATCHSET = "cherrypickofpatchset";
 
+  public static final String ARG_ID_NAME = "name";
   public static final String ARG_ID_USER = "user";
   public static final String ARG_ID_GROUP = "group";
   public static final String ARG_ID_OWNER = "owner";
   public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
 
+  public static final String OPERATOR_MERGED_BEFORE = "mergedbefore";
+  public static final String OPERATOR_MERGED_AFTER = "mergedafter";
+
+  // Operators to match on the last time the change was updated. Naming for legacy reasons.
+  public static final String OPERATOR_BEFORE = "before";
+  public static final String OPERATOR_AFTER = "after";
+
   private static final QueryBuilder.Definition<ChangeData, ChangeQueryBuilder> mydef =
       new QueryBuilder.Definition<>(ChangeQueryBuilder.class);
 
@@ -470,7 +480,7 @@
 
   @Operator
   public Predicate<ChangeData> before(String value) throws QueryParseException {
-    return new BeforePredicate(value);
+    return new BeforePredicate(ChangeField.UPDATED, ChangeQueryBuilder.OPERATOR_BEFORE, value);
   }
 
   @Operator
@@ -480,7 +490,7 @@
 
   @Operator
   public Predicate<ChangeData> after(String value) throws QueryParseException {
-    return new AfterPredicate(value);
+    return new AfterPredicate(ChangeField.UPDATED, ChangeQueryBuilder.OPERATOR_AFTER, value);
   }
 
   @Operator
@@ -489,6 +499,28 @@
   }
 
   @Operator
+  public Predicate<ChangeData> mergedBefore(String value) throws QueryParseException {
+    if (!args.index.getSchema().hasField(ChangeField.MERGED_ON)) {
+      throw new QueryParseException(
+          String.format(
+              "'%s' operator is not supported by change index version", OPERATOR_MERGED_BEFORE));
+    }
+    return new BeforePredicate(
+        ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> mergedAfter(String value) throws QueryParseException {
+    if (!args.index.getSchema().hasField(ChangeField.MERGED_ON)) {
+      throw new QueryParseException(
+          String.format(
+              "'%s' operator is not supported by change index version", OPERATOR_MERGED_AFTER));
+    }
+    return new AfterPredicate(
+        ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
+  }
+
+  @Operator
   public Predicate<ChangeData> change(String query) throws QueryParseException {
     Optional<ChangeTriplet> triplet = ChangeTriplet.parse(query);
     if (triplet.isPresent()) {
@@ -704,6 +736,16 @@
   }
 
   @Operator
+  public Predicate<ChangeData> parentof(String value) throws QueryParseException {
+    List<ChangeData> changes = parseChangeData(value);
+    List<Predicate<ChangeData>> or = new ArrayList<>(changes.size());
+    for (ChangeData c : changes) {
+      or.add(new ParentOfPredicate(value, c, args.repoManager));
+    }
+    return Predicate.or(or);
+  }
+
+  @Operator
   public Predicate<ChangeData> parentproject(String name) {
     return new ParentProjectPredicate(args.projectCache, args.childProjects, name);
   }
@@ -1034,7 +1076,7 @@
       for (GroupReference ref : suggestions) {
         ids.add(ref.getUUID());
       }
-      return visibleto(new SingleGroupUser(ids));
+      return visibleto(new GroupBackedUser(ids));
     }
 
     throw error("No user or group matches \"" + who + "\".");
@@ -1232,9 +1274,36 @@
   }
 
   @Operator
-  public Predicate<ChangeData> query(String name) throws QueryParseException {
+  public Predicate<ChangeData> query(String value) throws QueryParseException {
+    // [name=]<name>[,user=<user>] || [user=<user>,][name=]<name>
+    PredicateArgs inputArgs = new PredicateArgs(value);
+    String name = null;
+    Account.Id account = null;
+
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
-      VersionedAccountQueries q = VersionedAccountQueries.forUser(self());
+      // [name=]<name>
+      if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
+        name = inputArgs.keyValue.get(ARG_ID_NAME);
+      } else if (inputArgs.positional.size() == 1) {
+        name = Iterables.getOnlyElement(inputArgs.positional);
+      } else if (inputArgs.positional.size() > 1) {
+        throw new QueryParseException("Error parsing named query: " + value);
+      }
+
+      // [,user=<user>]
+      if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
+        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+        if (accounts != null && accounts.size() > 1) {
+          throw error(
+              String.format(
+                  "\"%s\" resolves to multiple accounts", inputArgs.keyValue.get(ARG_ID_USER)));
+        }
+        account = (accounts == null ? self() : Iterables.getOnlyElement(accounts));
+      } else {
+        account = self();
+      }
+
+      VersionedAccountQueries q = VersionedAccountQueries.forUser(account);
       q.load(args.allUsersName, git);
       String query = q.getQueryList().getQuery(name);
       if (query != null) {
@@ -1244,7 +1313,7 @@
       throw new QueryParseException(
           "Unknown named query (no " + args.allUsersName + " repo): " + name, e);
     } catch (IOException | ConfigInvalidException e) {
-      throw new QueryParseException("Error parsing named query: " + name, e);
+      throw new QueryParseException("Error parsing named query: " + value, e);
     }
     throw new QueryParseException("Unknown named query: " + name);
   }
@@ -1256,19 +1325,46 @@
   }
 
   @Operator
-  public Predicate<ChangeData> destination(String name) throws QueryParseException {
+  public Predicate<ChangeData> destination(String value) throws QueryParseException {
+    // [name=]<name>[,user=<user>] || [user=<user>,][name=]<name>
+    PredicateArgs inputArgs = new PredicateArgs(value);
+    String name = null;
+    Account.Id account = null;
+
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
-      VersionedAccountDestinations d = VersionedAccountDestinations.forUser(self());
+      // [name=]<name>
+      if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
+        name = inputArgs.keyValue.get(ARG_ID_NAME);
+      } else if (inputArgs.positional.size() == 1) {
+        name = Iterables.getOnlyElement(inputArgs.positional);
+      } else if (inputArgs.positional.size() > 1) {
+        throw new QueryParseException("Error parsing named destination: " + value);
+      }
+
+      // [,user=<user>]
+      if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
+        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+        if (accounts != null && accounts.size() > 1) {
+          throw error(
+              String.format(
+                  "\"%s\" resolves to multiple accounts", inputArgs.keyValue.get(ARG_ID_USER)));
+        }
+        account = (accounts == null ? self() : Iterables.getOnlyElement(accounts));
+      } else {
+        account = self();
+      }
+
+      VersionedAccountDestinations d = VersionedAccountDestinations.forUser(account);
       d.load(args.allUsersName, git);
       Set<BranchNameKey> destinations = d.getDestinationList().getDestinations(name);
       if (destinations != null && !destinations.isEmpty()) {
-        return new DestinationPredicate(destinations, name);
+        return new DestinationPredicate(destinations, value);
       }
     } catch (RepositoryNotFoundException e) {
       throw new QueryParseException(
           "Unknown named destination (no " + args.allUsersName + " repo): " + name, e);
     } catch (IOException | ConfigInvalidException e) {
-      throw new QueryParseException("Error parsing named destination: " + name, e);
+      throw new QueryParseException("Error parsing named destination: " + value, e);
     }
     throw new QueryParseException("Unknown named destination: " + name);
   }
@@ -1481,14 +1577,18 @@
   }
 
   private List<Change> parseChange(String value) throws QueryParseException {
+    return asChanges(parseChangeData(value));
+  }
+
+  private List<ChangeData> parseChangeData(String value) throws QueryParseException {
     if (PAT_LEGACY_ID.matcher(value).matches()) {
       Optional<Change.Id> id = Change.Id.tryParse(value);
       if (!id.isPresent()) {
         throw error("Invalid change id " + value);
       }
-      return asChanges(args.queryProvider.get().byLegacyChangeId(id.get()));
+      return args.queryProvider.get().byLegacyChangeId(id.get());
     } else if (PAT_CHANGE_ID.matcher(value).matches()) {
-      List<Change> changes = asChanges(args.queryProvider.get().byKeyPrefix(parseChangeId(value)));
+      List<ChangeData> changes = args.queryProvider.get().byKeyPrefix(parseChangeId(value));
       if (changes.isEmpty()) {
         throw error("Change " + value + " not found");
       }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 370bc75..ed1f2f1 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
@@ -33,10 +32,8 @@
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.account.AccountLimits;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
-import com.google.gerrit.server.change.PluginDefinedAttributesFactory;
 import com.google.gerrit.server.change.PluginDefinedInfosFactory;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
@@ -60,7 +57,6 @@
 public class ChangeQueryProcessor extends QueryProcessor<ChangeData>
     implements DynamicOptions.BeanReceiver, DynamicOptions.BeanProvider, PluginDefinedInfosFactory {
   private final Provider<CurrentUser> userProvider;
-  private final ImmutableListMultimap<String, ChangeAttributeFactory> attributeFactoriesByPlugin;
   private final ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory;
   private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
   private final List<Extension<ChangePluginDefinedInfoFactory>>
@@ -81,7 +77,6 @@
       IndexConfig indexConfig,
       ChangeIndexCollection indexes,
       ChangeIndexRewriter rewriter,
-      DynamicSet<ChangeAttributeFactory> attributeFactories,
       ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory,
       DynamicSet<ChangePluginDefinedInfoFactory> changePluginDefinedInfoFactories) {
     super(
@@ -95,14 +90,6 @@
     this.userProvider = userProvider;
     this.changeIsVisibleToPredicateFactory = changeIsVisibleToPredicateFactory;
 
-    ImmutableListMultimap.Builder<String, ChangeAttributeFactory> factoriesBuilder =
-        ImmutableListMultimap.builder();
-    ImmutableListMultimap.Builder<String, ChangePluginDefinedInfoFactory> infosFactoriesBuilder =
-        ImmutableListMultimap.builder();
-    // Eagerly call Extension#get() rather than storing Extensions, since that method invokes the
-    // Provider on every call, which could be expensive if we invoke it once for every change.
-    attributeFactories.entries().forEach(e -> factoriesBuilder.put(e.getPluginName(), e.get()));
-    attributeFactoriesByPlugin = factoriesBuilder.build();
     changePluginDefinedInfoFactories
         .entries()
         .forEach(e -> changePluginDefinedInfoFactoriesByPlugin.add(e));
@@ -130,18 +117,6 @@
     return dynamicBeans.get(plugin);
   }
 
-  public PluginDefinedAttributesFactory getAttributesFactory() {
-    return this::buildPluginInfo;
-  }
-
-  private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
-    return PluginDefinedAttributesFactories.createAll(
-        cd,
-        this,
-        attributeFactoriesByPlugin.entries().stream()
-            .map(e -> new Extension<>(e.getKey(), e::getValue)));
-  }
-
   public PluginDefinedInfosFactory getInfosFactory() {
     return this::createPluginDefinedInfos;
   }
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 4b46888..30d5e2f 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -73,7 +73,7 @@
     }
 
     boolean hasVote = false;
-    object.setLazyLoad(true);
+    object.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
     for (PatchSetApproval p : object.currentApprovals()) {
       if (labelType.matches(p)) {
         hasVote = true;
diff --git a/java/com/google/gerrit/server/query/change/GroupBackedUser.java b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
new file mode 100644
index 0000000..d0d5735
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.ListGroupMembership;
+import java.util.Set;
+
+/**
+ * Representation of a user that does not have a Gerrit account.
+ *
+ * <p>This user representation is intended to be used to check permissions for groups:
+ *
+ * <p>There are occasions where we need to check if a resource - such as a change - is accessible by
+ * a group. Our entire {@link com.google.gerrit.server.permissions.PermissionBackend} works solely
+ * with {@link CurrentUser}. This class can be used to check permissions on a synthetic user with
+ * the given group memberships. Any real Gerrit user with the same group memberships would receive
+ * the same permission check results.
+ */
+public final class GroupBackedUser extends CurrentUser {
+  private final GroupMembership groups;
+
+  /**
+   * Creates a new instance
+   *
+   * @param groups this set has to include all parent groups the user is contained in through
+   *     subgroup membership. Given a set of groups that contains the user directly, callers can use
+   *     {@link
+   *     com.google.gerrit.server.account.GroupIncludeCache#parentGroupsOf(AccountGroup.UUID)} to
+   *     resolve parent groups.
+   */
+  public GroupBackedUser(Set<AccountGroup.UUID> groups) {
+    this.groups = new ListGroupMembership(groups);
+  }
+
+  @Override
+  public GroupMembership getEffectiveGroups() {
+    return groups;
+  }
+
+  @Override
+  public String getLoggableName() {
+    return "GroupBackedUser with memberships: " + groups.getKnownGroups();
+  }
+
+  @Override
+  public Object getCacheKey() {
+    return groups.getKnownGroups();
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index b931457..1b6dc62 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -262,7 +262,8 @@
     }
 
     if (includeSubmitRecords) {
-      SubmitRuleOptions options = SubmitRuleOptions.builder().allowClosed(true).build();
+      SubmitRuleOptions options =
+          SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
       eventFactory.addSubmitRecords(c, submitRuleEvaluatorFactory.create(options).evaluate(d));
     }
 
@@ -330,15 +331,9 @@
       eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
     }
 
-    c.plugins = queryProcessor.getAttributesFactory().create(d);
     List<PluginDefinedInfo> pluginInfos = pluginInfosByChange.get(d.getId());
     if (!pluginInfos.isEmpty()) {
-      if (c.plugins == null) {
-        c.plugins = pluginInfos;
-      } else {
-        c.plugins = new ArrayList<>(c.plugins);
-        c.plugins.addAll(pluginInfos);
-      }
+      c.plugins = pluginInfos;
     }
     return c;
   }
diff --git a/java/com/google/gerrit/server/query/change/ParentOfPredicate.java b/java/com/google/gerrit/server/query/change/ParentOfPredicate.java
new file mode 100644
index 0000000..e48d586
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ParentOfPredicate.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.OperatorPredicate;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class ParentOfPredicate extends OperatorPredicate<ChangeData>
+    implements Matchable<ChangeData> {
+  protected final Set<RevCommit> parents;
+
+  public ParentOfPredicate(String value, ChangeData change, GitRepositoryManager repoManager) {
+    super(ChangeQueryBuilder.FIELD_PARENTOF, value);
+    this.parents = getParents(change, repoManager);
+  }
+
+  @Override
+  public boolean match(ChangeData changeData) {
+    return changeData.patchSets().stream().anyMatch(ps -> parents.contains(ps.commitId()));
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+
+  protected Set<RevCommit> getParents(ChangeData change, GitRepositoryManager repoManager) {
+    PatchSet ps = change.currentPatchSet();
+    try (Repository repo = repoManager.openRepository(change.project());
+        RevWalk walk = new RevWalk(repo)) {
+      RevCommit c = walk.parseCommit(ps.commitId());
+      return Sets.newHashSet(c.getParents());
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format(
+              "Loading commit %s for ps %d of change %d failed.",
+              ps.commitId(), ps.id().get(), ps.id().changeId().get()),
+          e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/SingleGroupUser.java b/java/com/google/gerrit/server/query/change/SingleGroupUser.java
deleted file mode 100644
index c451d46..0000000
--- a/java/com/google/gerrit/server/query/change/SingleGroupUser.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.GroupMembership;
-import com.google.gerrit.server.account.ListGroupMembership;
-import java.util.Set;
-
-public final class SingleGroupUser extends CurrentUser {
-  private final GroupMembership groups;
-
-  public SingleGroupUser(AccountGroup.UUID groupId) {
-    this(ImmutableSet.of(groupId));
-  }
-
-  public SingleGroupUser(Set<AccountGroup.UUID> groups) {
-    this.groups = new ListGroupMembership(groups);
-  }
-
-  @Override
-  public GroupMembership getEffectiveGroups() {
-    return groups;
-  }
-
-  @Override
-  public Object getCacheKey() {
-    return groups.getKnownGroups();
-  }
-}
diff --git a/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
index 8248bf5..f6b194b 100644
--- a/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.server.query.group;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.query.account.AccountQueryBuilder;
 
diff --git a/java/com/google/gerrit/server/query/group/GroupPredicates.java b/java/com/google/gerrit/server/query/group/GroupPredicates.java
index 5231c5a..992f60d 100644
--- a/java/com/google/gerrit/server/query/group/GroupPredicates.java
+++ b/java/com/google/gerrit/server/query/group/GroupPredicates.java
@@ -16,10 +16,10 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupField;
 import java.util.Locale;
 
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
index fbc8d0e..89c802d 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
@@ -32,7 +33,6 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.List;
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
index 86c574d..9e56807 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.group.GroupQueryBuilder.FIELD_LIMIT;
 
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.AndSource;
 import com.google.gerrit.index.query.IndexPredicate;
@@ -26,7 +27,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.index.group.GroupIndexRewriter;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
diff --git a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
index 5732873..b9b58b8 100644
--- a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
+++ b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
@@ -21,10 +21,10 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.InternalQuery;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.inject.Inject;
 import java.util.List;
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
index 66eab7b..8e6d8a1 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
@@ -38,6 +38,8 @@
  *
  * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
  * holding on to a single instance.
+ *
+ * <p>By default, enforces visibility to CurrentUser.
  */
 public class ProjectQueryProcessor extends QueryProcessor<ProjectData> {
   private final PermissionBackend permissionBackend;
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 4c07d8a..708e860 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -19,13 +19,11 @@
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/json",
-        "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/util/time",
-        "//java/com/google/gerrit/util/cli",
         "//lib:args4j",
         "//lib:blame-cache",
         "//lib:gson",
diff --git a/java/com/google/gerrit/server/restapi/RestApiModule.java b/java/com/google/gerrit/server/restapi/RestApiModule.java
index dc2fe5f..909c1f4 100644
--- a/java/com/google/gerrit/server/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/RestApiModule.java
@@ -29,5 +29,6 @@
     install(new com.google.gerrit.server.restapi.group.Module());
     install(new PluginRestApiModule());
     install(new com.google.gerrit.server.restapi.project.Module());
+    install(new com.google.gerrit.server.restapi.project.Module.BatchModule());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
index 119e2e4..61ff6b8 100644
--- a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
@@ -53,6 +53,7 @@
       return new AccountResource(accountResolver.resolve(id.get()).asUniqueUser());
     } catch (UnresolvableAccountException e) {
       if (e.isSelf()) {
+        // Must be authenticated to use 'me' or 'self'.
         throw new AuthException(e.getMessage(), e);
       }
       throw new ResourceNotFoundException(e.getMessage(), e);
diff --git a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
index 6df6c3c..9952987 100644
--- a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
+++ b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
@@ -60,8 +60,7 @@
 
   @Override
   public Response<List<SshKeyInfo>> apply(AccountResource rsrc)
-      throws AuthException, RepositoryNotFoundException, IOException, ConfigInvalidException,
-          PermissionBackendException {
+      throws AuthException, IOException, ConfigInvalidException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
diff --git a/java/com/google/gerrit/server/restapi/account/Module.java b/java/com/google/gerrit/server/restapi/account/Module.java
index 51055b8..7570465 100644
--- a/java/com/google/gerrit/server/restapi/account/Module.java
+++ b/java/com/google/gerrit/server/restapi/account/Module.java
@@ -76,8 +76,6 @@
     get(SSH_KEY_KIND).to(GetSshKey.class);
     delete(SSH_KEY_KIND).to(DeleteSshKey.class);
 
-    get(ACCOUNT_KIND, "oauthtoken").to(GetOAuthToken.class);
-
     get(ACCOUNT_KIND, "avatar").to(GetAvatar.class);
     get(ACCOUNT_KIND, "avatar.change.url").to(GetAvatarChangeUrl.class);
 
diff --git a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
index 17e31bd..2131070 100644
--- a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
@@ -94,7 +94,7 @@
       throws RestApiException, IOException, PermissionBackendException {
     Map<ProjectWatchKey, Set<NotifyType>> m = new HashMap<>();
     for (ProjectWatchInfo info : input) {
-      if (info.project == null) {
+      if (info.project == null || info.project.trim().isEmpty()) {
         throw new BadRequestException("project name must be specified");
       }
 
diff --git a/java/com/google/gerrit/server/restapi/account/PutActive.java b/java/com/google/gerrit/server/restapi/account/PutActive.java
index a80ab3f..3b431db 100644
--- a/java/com/google/gerrit/server/restapi/account/PutActive.java
+++ b/java/com/google/gerrit/server/restapi/account/PutActive.java
@@ -32,7 +32,7 @@
  *
  * <p>This REST endpoint handles {@code PUT /accounts/<account-identifier>/active} requests.
  *
- * <p>Only active accounts can login into Gerrit.
+ * <p>Only active accounts can login into Gerrit, or are suggested as reviewers.
  *
  * <p>Marking an account as inactive is handled by {@link DeleteActive}.
  */
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
index c108dcb..e67fe9e 100644
--- a/java/com/google/gerrit/server/restapi/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -17,6 +17,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -96,8 +97,7 @@
 
   @Singleton
   public static class Create
-      implements RestCollectionCreateView<
-          AccountResource, AccountResource.StarredChange, EmptyInput> {
+      implements RestCollectionCreateView<AccountResource, AccountResource.StarredChange, Input> {
     private final Provider<CurrentUser> self;
     private final ChangesCollection changes;
     private final StarredChangesUtil starredChangesUtil;
@@ -113,7 +113,7 @@
     }
 
     @Override
-    public Response<?> apply(AccountResource rsrc, IdString id, EmptyInput in)
+    public Response<?> apply(AccountResource rsrc, IdString id, Input in)
         throws RestApiException, IOException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to add starred change");
@@ -148,7 +148,7 @@
   }
 
   @Singleton
-  public static class Put implements RestModifyView<AccountResource.StarredChange, EmptyInput> {
+  public static class Put implements RestModifyView<AccountResource.StarredChange, Input> {
     private final Provider<CurrentUser> self;
 
     @Inject
@@ -157,8 +157,7 @@
     }
 
     @Override
-    public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
-        throws AuthException {
+    public Response<?> apply(AccountResource.StarredChange rsrc, Input in) throws AuthException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed update starred changes");
       }
@@ -167,7 +166,7 @@
   }
 
   @Singleton
-  public static class Delete implements RestModifyView<AccountResource.StarredChange, EmptyInput> {
+  public static class Delete implements RestModifyView<AccountResource.StarredChange, Input> {
     private final Provider<CurrentUser> self;
     private final StarredChangesUtil starredChangesUtil;
 
@@ -178,7 +177,7 @@
     }
 
     @Override
-    public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
+    public Response<?> apply(AccountResource.StarredChange rsrc, Input in)
         throws AuthException, IOException, IllegalLabelException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed remove starred change");
@@ -192,6 +191,4 @@
       return Response.none();
     }
   }
-
-  public static class EmptyInput {}
 }
diff --git a/java/com/google/gerrit/server/restapi/account/Stars.java b/java/com/google/gerrit/server/restapi/account/Stars.java
index c27bdd8..cc362f2 100644
--- a/java/com/google/gerrit/server/restapi/account/Stars.java
+++ b/java/com/google/gerrit/server/restapi/account/Stars.java
@@ -46,6 +46,12 @@
 import java.util.Set;
 import java.util.SortedSet;
 
+/**
+ * Implements adding label stars to changes.
+ *
+ * <p>This handles {@code POST} and {@code GET} for {@code
+ * /accounts/<account-identifier>/stars.changes/<change ID>}.
+ */
 @Singleton
 public class Stars implements ChildCollection<AccountResource, AccountResource.Star> {
 
@@ -70,6 +76,7 @@
   public Star parse(AccountResource parent, IdString id)
       throws RestApiException, PermissionBackendException, IOException {
     IdentifiedUser user = parent.getUser();
+    // This enforces visibility of the change.
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
     Set<String> labels = starredChangesUtil.getLabels(user.getAccountId(), change.getId());
     return new AccountResource.Star(user, change, labels);
@@ -87,6 +94,7 @@
 
   @Singleton
   public static class ListStarredChanges implements RestReadView<AccountResource> {
+
     private final Provider<CurrentUser> self;
     private final ChangesCollection changes;
 
@@ -121,6 +129,7 @@
 
   @Singleton
   public static class Get implements RestReadView<AccountResource.Star> {
+
     private final Provider<CurrentUser> self;
     private final StarredChangesUtil starredChangesUtil;
 
@@ -142,6 +151,7 @@
 
   @Singleton
   public static class Post implements RestModifyView<AccountResource.Star, StarsInput> {
+
     private final Provider<CurrentUser> self;
     private final StarredChangesUtil starredChangesUtil;
 
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
index a5be14f..cb1256c 100644
--- a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -42,6 +42,7 @@
 public class AddToAttentionSet
     implements RestCollectionModifyView<
         ChangeResource, AttentionSetEntryResource, AttentionSetInput> {
+
   private final BatchUpdate.Factory updateFactory;
   private final AccountResolver accountResolver;
   private final AddToAttentionSetOp.Factory opFactory;
@@ -72,8 +73,9 @@
   public Response<AccountInfo> apply(ChangeResource changeResource, AttentionSetInput input)
       throws Exception {
     AttentionSetUtil.validateInput(input);
+    Account.Id attentionUserId =
+        AttentionSetUtil.resolveAccount(accountResolver, changeResource.getNotes(), input.user);
 
-    Account.Id attentionUserId = accountResolver.resolve(input.user).asUnique().account().id();
     if (serviceUserClassifier.isServiceUser(attentionUserId)) {
       throw new BadRequestException(
           String.format(
diff --git a/java/com/google/gerrit/server/restapi/change/AttentionSet.java b/java/com/google/gerrit/server/restapi/change/AttentionSet.java
index 45d78dc..f72fe64ec 100644
--- a/java/com/google/gerrit/server/restapi/change/AttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AttentionSet.java
@@ -17,14 +17,15 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.server.change.AttentionSetEntryResource;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -58,12 +59,10 @@
 
   @Override
   public AttentionSetEntryResource parse(ChangeResource changeResource, IdString idString)
-      throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException {
-    try {
-      Account.Id accountId = accountResolver.resolve(idString.get()).asUnique().account().id();
-      return new AttentionSetEntryResource(changeResource, accountId);
-    } catch (UnresolvableAccountException e) {
-      throw new ResourceNotFoundException(idString, e);
-    }
+      throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException,
+          BadRequestException {
+    Account.Id accountId =
+        AttentionSetUtil.resolveAccount(accountResolver, changeResource.getNotes(), idString.get());
+    return new AttentionSetEntryResource(changeResource, accountId);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 7a15a1d..318b0fa 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -210,7 +210,7 @@
         }
         try {
           editInfo.files =
-              fileInfoJson.toFileInfoMap(
+              fileInfoJson.getFileInfoMap(
                   rsrc.getChange(), edit.get().getEditCommit(), basePatchSet);
         } catch (PatchListNotAvailableException e) {
           throw new ResourceNotFoundException(e.getMessage());
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 6ee3284c..ee6484c 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -44,7 +43,6 @@
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.change.ResetCherryPickOp;
 import com.google.gerrit.server.change.SetCherryPickOp;
-import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -114,7 +112,6 @@
   private final ApprovalsUtil approvalsUtil;
   private final NotifyResolver notifyResolver;
   private final BatchUpdate.Factory batchUpdateFactory;
-  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   CherryPickChange(
@@ -131,8 +128,7 @@
       ProjectCache projectCache,
       ApprovalsUtil approvalsUtil,
       NotifyResolver notifyResolver,
-      BatchUpdate.Factory batchUpdateFactory,
-      DynamicItem<UrlFormatter> urlFormatter) {
+      BatchUpdate.Factory batchUpdateFactory) {
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.gitManager = gitManager;
@@ -147,7 +143,6 @@
     this.approvalsUtil = approvalsUtil;
     this.notifyResolver = notifyResolver;
     this.batchUpdateFactory = batchUpdateFactory;
-    this.urlFormatter = urlFormatter;
   }
 
   /**
@@ -282,20 +277,36 @@
                 input.parent, commitToCherryPick.getParentCount()));
       }
 
-      String message = Strings.nullToEmpty(input.message).trim();
-      message = message.isEmpty() ? commitToCherryPick.getFullMessage() : message;
+      // If the commit message is not set, the commit message of the source commit will be used.
+      String commitMessage = Strings.nullToEmpty(input.message);
+      commitMessage = commitMessage.isEmpty() ? commitToCherryPick.getFullMessage() : commitMessage;
 
-      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverTimeZone);
+      String destChangeId = getDestinationChangeId(commitMessage, changeIdForNewChange);
 
-      final ObjectId generatedChangeId =
-          changeIdForNewChange != null
-              ? changeIdForNewChange
-              : CommitMessageUtil.generateChangeId();
-      String commitMessage = ChangeIdUtil.insertId(message, generatedChangeId).trim() + '\n';
+      ChangeData destChange = null;
+      if (destChangeId != null) {
+        // If "idForNewChange" is not null we must fail, since we are not expecting an already
+        // existing change.
+        destChange = getDestChangeWithVerification(destChangeId, dest, idForNewChange != null);
+      }
+
+      if (changeIdForNewChange != null) {
+        // If Change-Id was explicitly provided for the new change, override the value in commit
+        // message.
+        commitMessage = ChangeIdUtil.insertId(commitMessage, changeIdForNewChange, true);
+      } else if (destChangeId == null) {
+        // If commit message did not specify Change-Id, generate a new one and insert to the
+        // message.
+        commitMessage =
+            ChangeIdUtil.insertId(commitMessage, CommitMessageUtil.generateChangeId(), true);
+      }
+      commitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(commitMessage);
 
       CodeReviewCommit cherryPickCommit;
       ProjectState projectState =
           projectCache.get(dest.project()).orElseThrow(noSuchProject(dest.project()));
+      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverTimeZone);
+
       try {
         MergeUtil mergeUtil;
         if (input.allowConflicts) {
@@ -317,86 +328,54 @@
                 input.parent - 1,
                 input.allowEmpty,
                 input.allowConflicts);
-
-        Change.Key changeKey;
-        final List<String> idList =
-            ChangeUtil.getChangeIdsFromFooter(cherryPickCommit, urlFormatter.get());
-        if (!idList.isEmpty()) {
-          final String idStr = idList.get(idList.size() - 1).trim();
-          changeKey = Change.key(idStr);
-        } else {
-          changeKey = Change.key("I" + generatedChangeId.name());
-        }
-
-        BranchNameKey newDest = BranchNameKey.create(project, destRef.getName());
-        List<ChangeData> destChanges =
-            queryProvider.get().setLimit(2).byBranchKey(newDest, changeKey);
-        if (destChanges.size() > 1) {
-          throw new InvalidChangeOperationException(
-              "Several changes with key "
-                  + changeKey
-                  + " reside on the same branch. "
-                  + "Cannot create a new patch set.");
-        }
-        try (BatchUpdate bu = batchUpdateFactory.create(project, identifiedUser, timestamp)) {
-          bu.setRepository(git, revWalk, oi);
-          bu.setNotify(resolveNotify(input));
-          Change.Id changeId;
-          String newTopic = null;
-          if (input.topic != null) {
-            newTopic = Strings.emptyToNull(input.topic.trim());
-          }
-          if (newTopic == null
-              && sourceChange != null
-              && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
-            newTopic = sourceChange.getTopic() + "-" + newDest.shortName();
-          }
-          if (destChanges.size() == 1) {
-            // The change key exists on the destination branch. The cherry pick
-            // will be added as a new patch set. If "idForNewChange" is not null we must fail,
-            // since we are not expecting an already existing change.
-            if (idForNewChange != null) {
-              throw new InvalidChangeOperationException(
-                  String.format(
-                      "Expected that cherry-pick of commit %s with Change-Id %s to branch %s"
-                          + "in project %s creates a new change, but found existing change %d",
-                      sourceCommit.getName(),
-                      changeKey,
-                      dest.branch(),
-                      dest.project(),
-                      destChanges.get(0).getId().get()));
-            }
-            changeId =
-                insertPatchSet(
-                    bu,
-                    git,
-                    destChanges.get(0).notes(),
-                    cherryPickCommit,
-                    sourceChange,
-                    newTopic,
-                    workInProgress);
-          } else {
-            // Change key not found on destination branch. We can create a new
-            // change.
-            changeId =
-                createNewChange(
-                    bu,
-                    cherryPickCommit,
-                    dest.branch(),
-                    newTopic,
-                    project,
-                    sourceChange,
-                    sourceCommit,
-                    input,
-                    revertedChange,
-                    idForNewChange,
-                    workInProgress);
-          }
-          bu.execute();
-          return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
-        }
       } catch (MergeIdenticalTreeException | MergeConflictException e) {
-        throw new IntegrationConflictException("Cherry pick failed: " + e.getMessage());
+        throw new IntegrationConflictException("Cherry pick failed: " + e.getMessage(), e);
+      }
+
+      try (BatchUpdate bu = batchUpdateFactory.create(project, identifiedUser, timestamp)) {
+        bu.setRepository(git, revWalk, oi);
+        bu.setNotify(resolveNotify(input));
+        Change.Id changeId;
+        String newTopic = null;
+        if (input.topic != null) {
+          newTopic = Strings.emptyToNull(input.topic.trim());
+        }
+        if (newTopic == null
+            && sourceChange != null
+            && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
+          newTopic = sourceChange.getTopic() + "-" + dest.shortName();
+        }
+        if (destChange != null) {
+          // The change key exists on the destination branch. The cherry pick
+          // will be added as a new patch set.
+          changeId =
+              insertPatchSet(
+                  bu,
+                  git,
+                  destChange.notes(),
+                  cherryPickCommit,
+                  sourceChange,
+                  newTopic,
+                  workInProgress);
+        } else {
+          // Change key not found on destination branch. We can create a new
+          // change.
+          changeId =
+              createNewChange(
+                  bu,
+                  cherryPickCommit,
+                  dest.branch(),
+                  newTopic,
+                  project,
+                  sourceChange,
+                  sourceCommit,
+                  input,
+                  revertedChange,
+                  idForNewChange,
+                  workInProgress);
+        }
+        bu.execute();
+        return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
       }
     }
   }
@@ -576,4 +555,81 @@
 
     return stringBuilder.toString();
   }
+
+  /**
+   * Returns the Change-Id of destination change (as intended by the caller of cherry-pick
+   * operation).
+   *
+   * <p>The Change-Id can be provided in one of the following ways:
+   *
+   * <ul>
+   *   <li>Explicitly provided for the new change.
+   *   <li>Provided in the input commit message.
+   *   <li>Taken from the source commit if commit message was not set.
+   * </ul>
+   *
+   * Otherwise should be generated.
+   *
+   * @param commitMessage the commit message, as intended by the caller of cherry-pick operation.
+   * @param changeIdForNewChange the explicitly provided Change-Id for the new change.
+   * @return The Change-Id of destination change, {@code null} if Change-Id was not provided by the
+   *     caller of cherry-pick operation and should be generated.
+   */
+  @Nullable
+  private String getDestinationChangeId(
+      String commitMessage, @Nullable ObjectId changeIdForNewChange) {
+    if (changeIdForNewChange != null) {
+      return CommitMessageUtil.getChangeIdFromObjectId(changeIdForNewChange);
+    }
+    return CommitMessageUtil.getChangeIdFromCommitMessageFooter(commitMessage).orElse(null);
+  }
+
+  /**
+   * Returns the change from the destination branch, if it exists and is valid for the cherry-pick.
+   *
+   * @param destChangeId the Change-ID of the change in the destination branch.
+   * @param destBranch the branch to search by the Change-ID.
+   * @param verifyIsMissing if {@code true}, verifies that the change should be missing in the
+   *     destination branch.
+   * @return the verified change or {@code null} if the change was not found.
+   * @throws InvalidChangeOperationException if the change was found but failed validation
+   */
+  @Nullable
+  private ChangeData getDestChangeWithVerification(
+      String destChangeId, BranchNameKey destBranch, boolean verifyIsMissing)
+      throws InvalidChangeOperationException {
+    List<ChangeData> destChanges =
+        queryProvider.get().setLimit(2).byBranchKey(destBranch, Change.key(destChangeId));
+    if (destChanges.size() > 1) {
+      throw new InvalidChangeOperationException(
+          "Several changes with key "
+              + destChangeId
+              + " reside on the same branch. "
+              + "Cannot create a new patch set.");
+    }
+    if (destChanges.size() == 1 && verifyIsMissing) {
+      throw new InvalidChangeOperationException(
+          String.format(
+              "Expected that cherry-pick with Change-Id %s to branch %s "
+                  + "in project %s creates a new change, but found existing change %d",
+              destChangeId,
+              destBranch.branch(),
+              destBranch.project().get(),
+              destChanges.get(0).getId().get()));
+    }
+    ChangeData destChange = destChanges.size() == 1 ? destChanges.get(0) : null;
+
+    if (destChange != null && destChange.change().isClosed()) {
+      throw new InvalidChangeOperationException(
+          String.format(
+              "Cherry-pick with Change-Id %s could not update the existing change %d "
+                  + "in destination branch %s of project %s, because the change was closed (%s)",
+              destChangeId,
+              destChange.getId().get(),
+              destBranch.branch(),
+              destBranch.project(),
+              destChange.change().getStatus().name()));
+    }
+    return destChange;
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 4de9b63..edc8fcf 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -20,9 +20,12 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
 import com.google.gerrit.entities.HumanComment;
@@ -31,15 +34,18 @@
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.common.FixReplacementInfo;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.comment.CommentContextCache;
+import com.google.gerrit.server.comment.CommentContextKey;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
@@ -47,18 +53,20 @@
 public class CommentJson {
 
   private final AccountLoader.Factory accountLoaderFactory;
+  private final CommentContextCache commentContextCache;
+
+  private Project.NameKey project;
+  private Change.Id changeId;
 
   private boolean fillAccounts = true;
   private boolean fillPatchSet;
-  private CommentContextLoader.Factory commentContextLoaderFactory;
-  private CommentContextLoader commentContextLoader;
+  private boolean fillCommentContext;
+  private int contextPadding;
 
   @Inject
-  CommentJson(
-      AccountLoader.Factory accountLoaderFactory,
-      CommentContextLoader.Factory commentContextLoaderFactory) {
+  CommentJson(AccountLoader.Factory accountLoaderFactory, CommentContextCache commentContextCache) {
     this.accountLoaderFactory = accountLoaderFactory;
-    this.commentContextLoaderFactory = commentContextLoaderFactory;
+    this.commentContextCache = commentContextCache;
   }
 
   CommentJson setFillAccounts(boolean fillAccounts) {
@@ -71,10 +79,23 @@
     return this;
   }
 
-  CommentJson setEnableContext(boolean enableContext, Project.NameKey project) {
-    if (enableContext) {
-      this.commentContextLoader = commentContextLoaderFactory.create(project);
-    }
+  CommentJson setFillCommentContext(boolean fillCommentContext) {
+    this.fillCommentContext = fillCommentContext;
+    return this;
+  }
+
+  CommentJson setContextPadding(int contextPadding) {
+    this.contextPadding = contextPadding;
+    return this;
+  }
+
+  CommentJson setProjectKey(Project.NameKey project) {
+    this.project = project;
+    return this;
+  }
+
+  CommentJson setChangeId(Change.Id changeId) {
+    this.changeId = changeId;
     return this;
   }
 
@@ -93,9 +114,6 @@
       if (loader != null) {
         loader.fill();
       }
-      if (commentContextLoader != null) {
-        commentContextLoader.fill();
-      }
       return info;
     }
 
@@ -111,7 +129,6 @@
           list = new ArrayList<>();
           out.put(o.path, list);
         }
-        o.path = null;
         list.add(o);
       }
 
@@ -120,9 +137,12 @@
       if (loader != null) {
         loader.fill();
       }
-      if (commentContextLoader != null) {
-        commentContextLoader.fill();
+
+      List<T> allComments = out.values().stream().flatMap(Collection::stream).collect(toList());
+      if (fillCommentContext) {
+        addCommentContext(allComments);
       }
+      allComments.forEach(c -> c.path = null); // we don't need path since it exists in the map keys
       return out;
     }
 
@@ -138,12 +158,45 @@
       if (loader != null) {
         loader.fill();
       }
-      if (commentContextLoader != null) {
-        commentContextLoader.fill();
+
+      if (fillCommentContext) {
+        addCommentContext(out);
       }
+
       return out;
     }
 
+    protected void addCommentContext(List<T> allComments) {
+      List<CommentContextKey> keys =
+          allComments.stream().map(this::createCommentContextKey).collect(toList());
+      ImmutableMap<CommentContextKey, CommentContext> allContext = commentContextCache.getAll(keys);
+      for (T c : allComments) {
+        CommentContextKey contextKey = createCommentContextKey(c);
+        CommentContext commentContext = allContext.get(contextKey);
+        c.contextLines = toContextLineInfoList(commentContext);
+        c.sourceContentType = commentContext.contentType();
+      }
+    }
+
+    protected List<ContextLineInfo> toContextLineInfoList(CommentContext commentContext) {
+      List<ContextLineInfo> result = new ArrayList<>();
+      for (Map.Entry<Integer, String> e : commentContext.lines().entrySet()) {
+        result.add(new ContextLineInfo(e.getKey(), e.getValue()));
+      }
+      return result;
+    }
+
+    protected CommentContextKey createCommentContextKey(T r) {
+      return CommentContextKey.builder()
+          .project(project)
+          .changeId(changeId)
+          .id(r.id)
+          .path(r.path)
+          .patchset(r.patchSet)
+          .contextPadding(contextPadding)
+          .build();
+    }
+
     protected abstract T toInfo(F comment, AccountLoader loader);
 
     protected void fillCommentInfo(Comment c, CommentInfo r, AccountLoader loader) {
@@ -170,9 +223,6 @@
         r.author = loader.get(c.author.getId());
       }
       r.commitId = c.getCommitId().getName();
-      if (commentContextLoader != null) {
-        r.contextLines = commentContextLoader.getContext(r);
-      }
     }
 
     protected Range toRange(Comment.Range commentRange) {
diff --git a/java/com/google/gerrit/server/restapi/change/CommentPorter.java b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
index 681509c..34af285 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentPorter.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
@@ -18,7 +18,9 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static java.util.stream.Collectors.groupingBy;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
@@ -28,6 +30,9 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffMappings;
@@ -47,6 +52,7 @@
 import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Optional;
+import java.util.function.Function;
 import java.util.stream.Stream;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -62,15 +68,48 @@
 public class CommentPorter {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  @VisibleForTesting
+  @Singleton
+  static class Metrics {
+    final Counter0 portedAsPatchsetLevel;
+    final Counter0 portedAsFileLevel;
+    final Counter0 portedAsRangeComments;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      portedAsPatchsetLevel =
+          metricMaker.newCounter(
+              "ported_comments/as_patchset_level",
+              new Description("Total number of comments ported as patchset-level comments.")
+                  .setRate()
+                  .setUnit("count"));
+      portedAsFileLevel =
+          metricMaker.newCounter(
+              "ported_comments/as_file_level",
+              new Description("Total number of comments ported as file-level comments.")
+                  .setRate()
+                  .setUnit("count"));
+      portedAsRangeComments =
+          metricMaker.newCounter(
+              "ported_comments/as_range_comments",
+              new Description(
+                      "Total number of comments having line/range values in the ported patchset.")
+                  .setRate()
+                  .setUnit("count"));
+    }
+  }
+
   private final GitPositionTransformer positionTransformer =
       new GitPositionTransformer(BestPositionOnConflict.INSTANCE);
   private final PatchListCache patchListCache;
   private final CommentsUtil commentsUtil;
+  private final Metrics metrics;
 
   @Inject
-  public CommentPorter(PatchListCache patchListCache, CommentsUtil commentsUtil) {
+  public CommentPorter(PatchListCache patchListCache, CommentsUtil commentsUtil, Metrics metrics) {
     this.patchListCache = patchListCache;
     this.commentsUtil = commentsUtil;
+    this.metrics = metrics;
   }
 
   /**
@@ -204,9 +243,13 @@
 
     ImmutableList<PositionedEntity<HumanComment>> positionedComments =
         comments.stream().map(this::toPositionedEntity).collect(toImmutableList());
-    return positionTransformer.transform(positionedComments, mappings).stream()
-        .map(PositionedEntity::getEntityAtUpdatedPosition)
-        .collect(toImmutableList());
+    ImmutableMap<PositionedEntity<HumanComment>, HumanComment> origToPortedMap =
+        positionTransformer.transform(positionedComments, mappings).stream()
+            .collect(
+                ImmutableMap.toImmutableMap(
+                    Function.identity(), PositionedEntity::getEntityAtUpdatedPosition));
+    collectMetrics(origToPortedMap);
+    return ImmutableList.copyOf(origToPortedMap.values());
   }
 
   private ImmutableSet<Mapping> loadMappings(
@@ -269,6 +312,10 @@
     return positionBuilder.lineRange(extractLineRange(comment)).build();
   }
 
+  /**
+   * Returns {@link Optional#empty()} if the {@code comment} parameter is a file comment, or the
+   * comment range {start_line, end_line} otherwise.
+   */
   private static Optional<GitPositionTransformer.Range> extractLineRange(HumanComment comment) {
     // Line specifications in comment are 1-based. Line specifications in Position are 0-based.
     if (comment.range != null) {
@@ -316,6 +363,33 @@
     return new Range(lineRange.start() + 1, originalStartChar, adjustedEndLine, originalEndChar);
   }
 
+  /**
+   * Collect metrics from the original and ported comments.
+   *
+   * @param portMap map of the ported comments. The keys contain a {@link PositionedEntity} of the
+   *     original comment, and the values contain the transformed comments.
+   */
+  private void collectMetrics(ImmutableMap<PositionedEntity<HumanComment>, HumanComment> portMap) {
+    for (Map.Entry<PositionedEntity<HumanComment>, HumanComment> entry : portMap.entrySet()) {
+      HumanComment original = entry.getKey().getEntity();
+      HumanComment transformed = entry.getValue();
+
+      if (!Patch.isMagic(original.key.filename)) {
+        if (Patch.PATCHSET_LEVEL.equals(transformed.key.filename)) {
+          metrics.portedAsPatchsetLevel.increment();
+        } else if (extractLineRange(original).isPresent()) {
+          if (extractLineRange(transformed).isPresent()) {
+            metrics.portedAsRangeComments.increment();
+          } else {
+            // line range was present in the original comment, but the ported comment is a file
+            // level comment.
+            metrics.portedAsFileLevel.increment();
+          }
+        }
+      }
+    }
+  }
+
   /** A filter which just keeps those comments which are before the given patchset. */
   private static class EarlierPatchsetCommentFilter implements HumanCommentFilter {
 
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 52887e0..c392bd1 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -223,6 +223,12 @@
       throw new BadRequestException("branch must be non-empty");
     }
     input.branch = RefNames.fullName(input.branch);
+    if (!isBranchAllowed(input.branch)) {
+      throw new BadRequestException(
+          "Cannot create a change on ref "
+              + input.branch
+              + ". Gerrit internal refs and refs/tags/* are not allowed.");
+    }
 
     String subject = Strings.nullToEmpty(input.subject);
     subject = subject.replaceAll("(?m)^#.*$\n?", "").trim();
@@ -292,6 +298,11 @@
     }
   }
 
+  /** Changes are allowed to be created on any ref that is not Gerrit internal or a tag ref. */
+  private boolean isBranchAllowed(String branch) {
+    return !RefNames.isGerritRef(branch) && !branch.startsWith(RefNames.REFS_TAGS);
+  }
+
   private void checkRequiredPermissions(
       Project.NameKey project, String refName, @Nullable AccountInput author)
       throws ResourceNotFoundException, AuthException, PermissionBackendException {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
index 20fd675..842ed2a 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
@@ -93,10 +93,7 @@
       }
       IdentifiedUser deletedAssigneeUser = userFactory.create(currentAssigneeId);
       deletedAssignee = deletedAssigneeUser.state();
-      // noteDb
       update.removeAssignee();
-      // reviewDb
-      change.setAssignee(null);
       addMessage(ctx, update, deletedAssigneeUser);
       return true;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index 392aef7..f82284e 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -163,7 +163,7 @@
             revisions.parse(resource.getChangeResource(), IdString.fromDecoded(base));
         r =
             Response.ok(
-                fileInfoJson.toFileInfoMap(
+                fileInfoJson.getFileInfoMap(
                     resource.getChange(),
                     resource.getPatchSet().commitId(),
                     baseResource.getPatchSet()));
@@ -180,10 +180,10 @@
         }
         r =
             Response.ok(
-                fileInfoJson.toFileInfoMap(
+                fileInfoJson.getFileInfoMap(
                     resource.getChange(), resource.getPatchSet().commitId(), parentNum - 1));
       } else {
-        r = Response.ok(fileInfoJson.toFileInfoMap(resource.getChange(), resource.getPatchSet()));
+        r = Response.ok(fileInfoJson.getFileInfoMap(resource.getChange(), resource.getPatchSet()));
       }
 
       if (resource.isCacheable()) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetBlame.java b/java/com/google/gerrit/server/restapi/change/GetBlame.java
index 12b4d44..04828f2 100644
--- a/java/com/google/gerrit/server/restapi/change/GetBlame.java
+++ b/java/com/google/gerrit/server/restapi/change/GetBlame.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.change.FileResource;
 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.project.InvalidChangeOperationException;
@@ -39,7 +40,6 @@
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -86,7 +86,7 @@
       throws RestApiException, IOException, InvalidChangeOperationException {
     Project.NameKey project = resource.getRevision().getChange().getProject();
     try (Repository repository = repoManager.openRepository(project);
-        ObjectInserter ins = repository.newObjectInserter();
+        InMemoryInserter ins = new InMemoryInserter(repository);
         ObjectReader reader = ins.newReader();
         RevWalk revWalk = new RevWalk(reader)) {
       String refName =
@@ -115,7 +115,9 @@
         result = blame(parents[0], path, repository, revWalk);
 
       } else if (parents.length == 2) {
-        ObjectId automerge = autoMerger.merge(repository, revWalk, ins, revCommit, mergeStrategy);
+        ObjectId automerge =
+            autoMerger.lookupFromGitOrMergeInMemory(
+                repository, revWalk, ins, revCommit, mergeStrategy);
         result = blame(automerge, path, repository, revWalk);
 
       } else {
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index 1ef3c4b..340b9a0 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -14,31 +14,43 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.MissingMetaObjectException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+import java.io.IOException;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.kohsuke.args4j.Option;
 
 public class GetChange
@@ -46,16 +58,23 @@
         DynamicOptions.BeanReceiver,
         DynamicOptions.BeanProvider {
   private final ChangeJson.Factory json;
-  private final DynamicSet<ChangeAttributeFactory> attrFactories;
   private final DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories;
   private final EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
   private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
+  private final GitRepositoryManager repoMgr;
 
   @Option(name = "-o", usage = "Output options")
   public void addOption(ListChangesOption o) {
     options.add(o);
   }
 
+  @Option(name = "--meta", usage = "NoteDb meta SHA1")
+  String metaRevId = "";
+
+  public void setMetaRevId(String metaRevId) {
+    this.metaRevId = metaRevId == null ? "" : metaRevId;
+  }
+
   @Option(name = "-O", usage = "Output option flags, in hex")
   void setOptionFlagsHex(String hex) {
     options.addAll(ListOption.fromBits(ListChangesOption.class, Integer.parseInt(hex, 16)));
@@ -64,11 +83,11 @@
   @Inject
   GetChange(
       ChangeJson.Factory json,
-      DynamicSet<ChangeAttributeFactory> attrFactories,
-      DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories) {
+      DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories,
+      GitRepositoryManager repoMgr) {
     this.json = json;
-    this.attrFactories = attrFactories;
     this.pdiFactories = pdiFactories;
+    this.repoMgr = repoMgr;
   }
 
   @Override
@@ -82,21 +101,40 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc) {
-    return Response.withMustRevalidate(newChangeJson().format(rsrc));
+  public Response<ChangeInfo> apply(ChangeResource rsrc) throws RestApiException {
+    try {
+      Change change = rsrc.getChange();
+      ObjectId changeMetaRevId = getMetaRevId(change);
+      return Response.withMustRevalidate(newChangeJson().format(change, changeMetaRevId));
+    } catch (MissingMetaObjectException e) {
+      throw new PreconditionFailedException(e.getMessage());
+    }
   }
 
   Response<ChangeInfo> apply(RevisionResource rsrc) {
     return Response.withMustRevalidate(newChangeJson().format(rsrc));
   }
 
-  private ChangeJson newChangeJson() {
-    return json.create(options, this::buildPluginInfo, this::createPluginDefinedInfos);
+  @Nullable
+  private ObjectId getMetaRevId(Change change) throws RestApiException {
+    if (metaRevId.isEmpty()) {
+      return null;
+    }
+
+    // It might be interesting to also allow {SHA1}^^, so callers can walk back into history
+    // without having to fetch the entire /meta ref. If we do so, we have to be careful that
+    // the error messages can't be abused to fetch hidden data.
+    ObjectId metaRevObjectId;
+    try {
+      metaRevObjectId = ObjectId.fromString(metaRevId);
+    } catch (InvalidObjectIdException e) {
+      throw new BadRequestException("invalid meta SHA1: " + metaRevId, e);
+    }
+    return verifyMetaId(change, metaRevObjectId);
   }
 
-  private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
-    return PluginDefinedAttributesFactories.createAll(
-        cd, this, Streams.stream(attrFactories.entries()));
+  private ChangeJson newChangeJson() {
+    return json.create(options, this::createPluginDefinedInfos);
   }
 
   private ImmutableListMultimap<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
@@ -104,4 +142,34 @@
     return PluginDefinedAttributesFactories.createAll(
         cds, this, Streams.stream(pdiFactories.entries()));
   }
+
+  private ObjectId verifyMetaId(Change change, @Nullable ObjectId id) throws RestApiException {
+    if (id == null) {
+      return null;
+    }
+
+    String changeMetaRefName = RefNames.changeMetaRef(change.getId());
+    try (Repository repo = repoMgr.openRepository(change.getProject());
+        RevWalk rw = new RevWalk(repo)) {
+      rw.setRetainBody(false);
+      Ref ref = repo.getRefDatabase().exactRef(changeMetaRefName);
+      RevCommit tip = rw.parseCommit(ref.getObjectId());
+      rw.markStart(tip);
+      for (RevCommit rev : rw) {
+        if (id.equals(rev)) {
+          return id;
+        }
+      }
+    } catch (IOException e) {
+      throw RestApiException.wrap(
+          "I/O error while reading meta-ref id="
+              + id.getName()
+              + " from change "
+              + change.getChangeId(),
+          e);
+    }
+
+    throw new PreconditionFailedException(
+        id.getName() + " not reachable from " + changeMetaRefName);
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetCommit.java b/java/com/google/gerrit/server/restapi/change/GetCommit.java
index 21a08dc..d76ce04 100644
--- a/java/com/google/gerrit/server/restapi/change/GetCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/GetCommit.java
@@ -58,7 +58,13 @@
       rw.parseBody(commit);
       CommitInfo info =
           json.create(ImmutableSet.of())
-              .getCommitInfo(rsrc.getProject(), rw, commit, addLinks, true);
+              .getCommitInfo(
+                  rsrc.getProject(),
+                  rw,
+                  commit,
+                  addLinks,
+                  /* fillCommit= */ true,
+                  rsrc.getChange().getDest().branch());
       Response<CommitInfo> r = Response.ok(info);
       if (rsrc.isCacheable()) {
         r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
diff --git a/java/com/google/gerrit/server/restapi/change/GetDetail.java b/java/com/google/gerrit/server/restapi/change/GetDetail.java
index e31d84b..4139560 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDetail.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
@@ -58,7 +59,7 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc) {
+  public Response<ChangeInfo> apply(ChangeResource rsrc) throws RestApiException {
     return delegate.apply(rsrc);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index 8d51786..19256bb 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.RevisionResource;
@@ -51,6 +52,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.concurrent.TimeUnit;
 import org.kohsuke.args4j.CmdLineParser;
@@ -67,6 +69,7 @@
   private final PatchScriptFactory.Factory patchScriptFactoryFactory;
   private final Revisions revisions;
   private final WebLinks webLinks;
+  private final Provider<CurrentUser> currentUser;
 
   @Option(name = "--base", metaVar = "REVISION")
   String base;
@@ -93,11 +96,13 @@
       ProjectCache projectCache,
       PatchScriptFactory.Factory patchScriptFactoryFactory,
       Revisions revisions,
-      WebLinks webLinks) {
+      WebLinks webLinks,
+      Provider<CurrentUser> currentUser) {
     this.projectCache = projectCache;
     this.patchScriptFactoryFactory = patchScriptFactoryFactory;
     this.revisions = revisions;
     this.webLinks = webLinks;
+    this.currentUser = currentUser;
   }
 
   @Override
@@ -132,11 +137,15 @@
       if (basePatchSet.id().get() == 0) {
         throw new BadRequestException("edit not allowed as base");
       }
-      psf = patchScriptFactoryFactory.create(notes, fileName, basePatchSet.id(), pId, prefs);
+      psf =
+          patchScriptFactoryFactory.create(
+              notes, fileName, basePatchSet.id(), pId, prefs, currentUser.get());
     } else if (parentNum > 0) {
-      psf = patchScriptFactoryFactory.create(notes, fileName, parentNum - 1, pId, prefs);
+      psf =
+          patchScriptFactoryFactory.create(
+              notes, fileName, parentNum - 1, pId, prefs, currentUser.get());
     } else {
-      psf = patchScriptFactoryFactory.create(notes, fileName, null, pId, prefs);
+      psf = patchScriptFactoryFactory.create(notes, fileName, null, pId, prefs, currentUser.get());
     }
 
     try {
@@ -174,6 +183,8 @@
     private final DiffSide sideB;
     private final String revA;
     private final String revB;
+    private final String hashA;
+    private final String hashB;
     private final FileResource resource;
     @Nullable private final PatchSet basePatchSet;
 
@@ -192,6 +203,7 @@
       this.sideB = sideB;
 
       revA = basePatchSet != null ? basePatchSet.refName() : sideA.fileInfo().commitId;
+      hashA = sideA.fileInfo().commitId;
 
       RevisionResource revision = resource.getRevision();
       revB =
@@ -199,8 +211,9 @@
               .getEdit()
               .map(edit -> edit.getRefName())
               .orElseGet(() -> revision.getPatchSet().refName());
+      hashB = sideB.fileInfo().commitId;
 
-      logger.atFine().log("revA = %s, revB = %s", revA, revB);
+      logger.atFine().log("revA = %s, hashA = %s, revB = %s, hashB = %s", revA, hashA, revB, hashB);
     }
 
     @Override
@@ -219,15 +232,18 @@
     @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;
       }
-      return webLinks.getFileLinks(projectName.get(), rev, side.fileName());
+      return webLinks.getFileLinks(projectName.get(), rev, hash, side.fileName());
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/GetMergeList.java b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
index 0c67fd6..f0639b5 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMergeList.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
@@ -81,7 +81,14 @@
       List<CommitInfo> result = new ArrayList<>(commits.size());
       RevisionJson changeJson = json.create(ImmutableSet.of());
       for (RevCommit c : commits) {
-        result.add(changeJson.getCommitInfo(rsrc.getProject(), rw, c, addLinks, true));
+        result.add(
+            changeJson.getCommitInfo(
+                rsrc.getProject(),
+                rw,
+                c,
+                addLinks,
+                /* fillCommit= */ true,
+                rsrc.getChange().getDest().branch()));
       }
       return createResponse(rsrc, result);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java b/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java
new file mode 100644
index 0000000..1e71d4c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java
@@ -0,0 +1,162 @@
+// 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 static com.google.gerrit.entities.RefNames.changeMetaRef;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ListOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInfoDiffer;
+import com.google.gerrit.extensions.common.ChangeInfoDifference;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.DynamicOptions.DynamicBean;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+/** Gets the diff for a change at two NoteDb meta SHA-1s. */
+public class GetMetaDiff
+    implements RestReadView<ChangeResource>,
+        DynamicOptions.BeanReceiver,
+        DynamicOptions.BeanProvider {
+
+  private final EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
+  private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
+
+  private final Provider<GetChange> getChangeProvider;
+  private final GitRepositoryManager repoManager;
+
+  @Option(name = "-o", usage = "Output options")
+  public void addOption(ListChangesOption o) {
+    options.add(o);
+  }
+
+  @Option(name = "-O", usage = "Output option flags, in hex")
+  void setOptionFlagsHex(String hex) {
+    options.addAll(ListOption.fromBits(ListChangesOption.class, Integer.parseInt(hex, 16)));
+  }
+
+  @Option(name = "--old", usage = "old NoteDb meta SHA-1")
+  String oldMetaRevId = "";
+
+  public void setOldMetaRevId(@Nullable String oldMetaRevId) {
+    this.oldMetaRevId = oldMetaRevId == null ? "" : oldMetaRevId;
+  }
+
+  @Option(name = "--meta", usage = "new NoteDb meta SHA-1")
+  String metaRevId = "";
+
+  public void setNewMetaRevId(@Nullable String metaRevId) {
+    this.metaRevId = metaRevId == null ? "" : metaRevId;
+  }
+
+  @Inject
+  GetMetaDiff(Provider<GetChange> getChangeProvider, GitRepositoryManager repoManager) {
+    this.getChangeProvider = getChangeProvider;
+    this.repoManager = repoManager;
+  }
+
+  @Override
+  public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
+    dynamicBeans.put(plugin, dynamicBean);
+  }
+
+  @Override
+  public DynamicBean getDynamicBean(String plugin) {
+    return dynamicBeans.get(plugin);
+  }
+
+  @Override
+  public Response<ChangeInfoDifference> apply(ChangeResource resource)
+      throws RestApiException, IOException {
+    return Response.ok(
+        ChangeInfoDiffer.getDifference(getOldChangeInfo(resource), getNewChangeInfo(resource)));
+  }
+
+  private ChangeInfo getOldChangeInfo(ChangeResource resource)
+      throws RestApiException, IOException {
+    GetChange getChange = createGetChange();
+    getChange.setMetaRevId(getOldMetaRevId(resource));
+    ChangeInfo oldChangeInfo;
+    try {
+      oldChangeInfo = getChange.apply(resource).value();
+    } catch (PreconditionFailedException e) {
+      oldChangeInfo = new ChangeInfo();
+    }
+    return oldChangeInfo;
+  }
+
+  private String getOldMetaRevId(ChangeResource resource)
+      throws IOException, BadRequestException, PreconditionFailedException {
+    if (!oldMetaRevId.isEmpty()) {
+      return oldMetaRevId;
+    }
+    String newMetaRevId = getNewMetaRevId(resource);
+    try (Repository repo = repoManager.openRepository(resource.getProject());
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId resourceId = ObjectId.fromString(newMetaRevId);
+      RevCommit commit = rw.parseCommit(resourceId);
+      return commit.getParentCount() == 0
+          ? resourceId.getName()
+          : commit.getParent(0).getId().getName();
+    } catch (InvalidObjectIdException e) {
+      throw new BadRequestException("invalid meta SHA1: " + newMetaRevId, e);
+    } catch (MissingObjectException e) {
+      throw new PreconditionFailedException(e.getMessage());
+    }
+  }
+
+  private ChangeInfo getNewChangeInfo(ChangeResource resource)
+      throws RestApiException, IOException {
+    GetChange getChange = createGetChange();
+    getChange.setMetaRevId(getNewMetaRevId(resource));
+    return getChange.apply(resource).value();
+  }
+
+  private String getNewMetaRevId(ChangeResource resource) throws IOException {
+    if (!metaRevId.isEmpty()) {
+      return metaRevId;
+    }
+    try (Repository repo = repoManager.openRepository(resource.getProject())) {
+      return repo.exactRef(changeMetaRef(resource.getId())).getObjectId().getName();
+    }
+  }
+
+  private GetChange createGetChange() {
+    GetChange getChange = getChangeProvider.get();
+    options.forEach(getChange::addOption);
+    dynamicBeans.forEach(getChange::setDynamicBean);
+    return getChange;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
index c4da3b6..527129c 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
@@ -14,67 +14,26 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.ActionInfo;
-import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.ActionJson;
-import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.submit.ChangeSet;
-import com.google.gerrit.server.submit.MergeSuperSet;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.Map;
-import org.eclipse.jgit.lib.Config;
 
 @Singleton
-public class GetRevisionActions implements ETagView<RevisionResource> {
+public class GetRevisionActions implements RestReadView<RevisionResource> {
   private final ActionJson delegate;
-  private final Config config;
-  private final Provider<MergeSuperSet> mergeSuperSet;
-  private final ChangeResource.Factory changeResourceFactory;
 
   @Inject
-  GetRevisionActions(
-      ActionJson delegate,
-      Provider<MergeSuperSet> mergeSuperSet,
-      ChangeResource.Factory changeResourceFactory,
-      @GerritServerConfig Config config) {
+  GetRevisionActions(ActionJson delegate) {
     this.delegate = delegate;
-    this.mergeSuperSet = mergeSuperSet;
-    this.changeResourceFactory = changeResourceFactory;
-    this.config = config;
   }
 
   @Override
   public Response<Map<String, ActionInfo>> apply(RevisionResource rsrc) {
     return Response.withMustRevalidate(delegate.format(rsrc));
   }
-
-  @Override
-  public String getETag(RevisionResource rsrc) {
-    Hasher h = Hashing.murmur3_128().newHasher();
-    CurrentUser user = rsrc.getUser();
-    try {
-      rsrc.getChangeResource().prepareETag(h, user);
-      h.putBoolean(MergeSuperSet.wholeTopicEnabled(config));
-      ChangeSet cs = mergeSuperSet.get().completeChangeSet(rsrc.getChange(), user);
-      for (ChangeData cd : cs.changes()) {
-        changeResourceFactory.create(cd.notes(), user).prepareETag(h, user);
-      }
-      h.putBoolean(cs.furtherHiddenChanges());
-    } catch (IOException | PermissionBackendException e) {
-      throw new StorageException(e);
-    }
-    return h.hash().toString();
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index e3b433c..c90e4fc 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -19,7 +19,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -43,6 +42,7 @@
   private final CommentsUtil commentsUtil;
 
   private boolean includeContext;
+  private int contextPadding;
 
   /**
    * Optional parameter. If set, the contextLines field of the {@link ContextLineInfo} of the
@@ -55,6 +55,16 @@
     this.includeContext = context;
   }
 
+  /**
+   * Optional parameter. Works only if {@link #includeContext} is set to true. If {@link
+   * #contextPadding} is set, the context lines in the response will be padded with {@link
+   * #contextPadding} extra lines before and after the comment range.
+   */
+  @Option(name = "--context-padding")
+  public void setContextPadding(int contextPadding) {
+    this.contextPadding = contextPadding;
+  }
+
   @Inject
   ListChangeComments(
       ChangeData.Factory changeDataFactory,
@@ -84,8 +94,7 @@
 
   private ImmutableList<CommentInfo> getAsList(Iterable<HumanComment> comments, ChangeResource rsrc)
       throws PermissionBackendException {
-    ImmutableList<CommentInfo> commentInfos =
-        getCommentFormatter(rsrc.getProject()).formatAsList(comments);
+    ImmutableList<CommentInfo> commentInfos = getCommentFormatter(rsrc).formatAsList(comments);
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
     CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, true);
     return commentInfos;
@@ -93,8 +102,7 @@
 
   private Map<String, List<CommentInfo>> getAsMap(
       Iterable<HumanComment> comments, ChangeResource rsrc) throws PermissionBackendException {
-    Map<String, List<CommentInfo>> commentInfosMap =
-        getCommentFormatter(rsrc.getProject()).format(comments);
+    Map<String, List<CommentInfo>> commentInfosMap = getCommentFormatter(rsrc).format(comments);
     List<CommentInfo> commentInfos =
         commentInfosMap.values().stream().flatMap(List::stream).collect(toList());
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
@@ -102,12 +110,15 @@
     return commentInfosMap;
   }
 
-  private CommentJson.HumanCommentFormatter getCommentFormatter(Project.NameKey project) {
+  private CommentJson.HumanCommentFormatter getCommentFormatter(ChangeResource rsrc) {
     return commentJson
         .get()
         .setFillAccounts(true)
         .setFillPatchSet(true)
-        .setEnableContext(includeContext, project)
+        .setFillCommentContext(includeContext)
+        .setContextPadding(contextPadding)
+        .setProjectKey(rsrc.getProject())
+        .setChangeId(rsrc.getId())
         .newHumanCommentFormatter();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
index 3841dc1..65f90ae 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -29,6 +30,7 @@
 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> {
@@ -36,6 +38,30 @@
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
 
+  private boolean includeContext;
+  private int contextPadding;
+
+  /**
+   * Optional parameter. If set, the contextLines field of the {@link ContextLineInfo} of the
+   * response will contain the lines of the source file where the comment was written.
+   *
+   * @param context If true, comment context will be attached to the response
+   */
+  @Option(name = "--enable-context")
+  public void setContext(boolean context) {
+    this.includeContext = context;
+  }
+
+  /**
+   * Optional parameter. Works only if {@link #includeContext} is set to true. If {@link
+   * #contextPadding} is set, the context lines in the response will be padded with {@link
+   * #contextPadding} extra lines before and after the comment range.
+   */
+  @Option(name = "--context-padding")
+  public void setContextPadding(int contextPadding) {
+    this.contextPadding = contextPadding;
+  }
+
   @Inject
   ListChangeDrafts(
       ChangeData.Factory changeDataFactory,
@@ -57,7 +83,7 @@
     if (!rsrc.getUser().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
-    return Response.ok(getCommentFormatter().format(listComments(rsrc)));
+    return Response.ok(getCommentFormatter(rsrc).format(listComments(rsrc)));
   }
 
   public List<CommentInfo> getComments(ChangeResource rsrc)
@@ -65,14 +91,18 @@
     if (!rsrc.getUser().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
-    return getCommentFormatter().formatAsList(listComments(rsrc));
+    return getCommentFormatter(rsrc).formatAsList(listComments(rsrc));
   }
 
-  private HumanCommentFormatter getCommentFormatter() {
+  private HumanCommentFormatter getCommentFormatter(ChangeResource rsrc) {
     return commentJson
         .get()
         .setFillAccounts(false)
         .setFillPatchSet(true)
+        .setFillCommentContext(includeContext)
+        .setContextPadding(contextPadding)
+        .setProjectKey(rsrc.getProject())
+        .setChangeId(rsrc.getId())
         .newHumanCommentFormatter();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java b/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
index 9b254f1..e92fe5c 100644
--- a/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
@@ -18,7 +18,9 @@
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
@@ -46,7 +48,10 @@
 
   @Override
   public Response<Map<String, List<CommentInfo>>> apply(RevisionResource revisionResource)
-      throws PermissionBackendException {
+      throws PermissionBackendException, RestApiException {
+    if (!revisionResource.getUser().isIdentifiedUser()) {
+      throw new AuthException("requires authentication; only authenticated users can have drafts");
+    }
     PatchSet targetPatchset = revisionResource.getPatchSet();
 
     List<HumanComment> draftComments =
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index 69e2788..f87c9a1 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -29,7 +29,6 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
@@ -47,7 +46,9 @@
 import com.google.gerrit.server.change.SetCherryPickOp;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.change.SetPrivateOp;
+import com.google.gerrit.server.change.SetTopicOp;
 import com.google.gerrit.server.change.WorkInProgressOp;
+import com.google.gerrit.server.comment.CommentContextLoader;
 import com.google.gerrit.server.restapi.change.Reviewed.DeleteReviewed;
 import com.google.gerrit.server.restapi.change.Reviewed.PutReviewed;
 import com.google.gerrit.server.util.AttentionSetEmail;
@@ -81,6 +82,7 @@
 
     postOnCollection(CHANGE_KIND).to(CreateChange.class);
     get(CHANGE_KIND).to(GetChange.class);
+    get(CHANGE_KIND, "meta_diff").to(GetMetaDiff.class);
     post(CHANGE_KIND, "merge").to(CreateMergePatchSet.class);
     get(CHANGE_KIND, "detail").to(GetDetail.class);
     get(CHANGE_KIND, "topic").to(GetTopic.class);
@@ -218,9 +220,9 @@
     factory(SetAssigneeOp.Factory.class);
     factory(SetCherryPickOp.Factory.class);
     factory(SetHashtagsOp.Factory.class);
+    factory(SetTopicOp.Factory.class);
     factory(SetPrivateOp.Factory.class);
     factory(WorkInProgressOp.Factory.class);
-    factory(SetTopicOp.Factory.class);
     factory(AddToAttentionSetOp.Factory.class);
     factory(RemoveFromAttentionSetOp.Factory.class);
     factory(AttentionSetEmail.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index ecfb96d..fc7e9f4 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -140,6 +141,18 @@
     // Not allowed to move if the current patch set is locked.
     psUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
+    // Keeping all votes can be confusing in the context of the destination branch, see the
+    // discussion in
+    // https://gerrit-review.googlesource.com/c/gerrit/+/129171
+    // Only administrators are allowed to keep all labels at their own risk.
+    try {
+      if (input.keepAllVotes) {
+        permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
+      }
+    } catch (AuthException denied) {
+      throw new AuthException("move is not permitted with keepAllVotes option", denied);
+    }
+
     // Move requires abandoning this change, and creating a new change.
     try {
       rsrc.permissions().check(ABANDON);
@@ -223,7 +236,9 @@
       update.setBranch(newDestKey.branch());
       change.setDest(newDestKey);
 
-      updateApprovals(ctx, update, psId, projectKey);
+      if (!input.keepAllVotes) {
+        updateApprovals(ctx, update, psId, projectKey);
+      }
 
       StringBuilder msgBuf = new StringBuilder();
       msgBuf.append("Change destination moved from ");
diff --git a/java/com/google/gerrit/server/restapi/change/OnPostReview.java b/java/com/google/gerrit/server/restapi/change/OnPostReview.java
new file mode 100644
index 0000000..b179d02
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/OnPostReview.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import java.util.Map;
+import java.util.Optional;
+
+/** Extension point that is invoked on post review. */
+@ExtensionPoint
+public interface OnPostReview {
+  /**
+   * Allows implementors to return a message that should be included into the change message that is
+   * posted on post review.
+   *
+   * @param user the user that posts the review
+   * @param changeNotes the change on which post review is performed
+   * @param patchSet the patch set on which post review is performed
+   * @param oldApprovals old approvals that changed as result of post review
+   * @param approvals all current approvals
+   * @return message that should be included into the change message that is posted on post review,
+   *     {@link Optional#empty()} if the change message should not be extended
+   */
+  default Optional<String> getChangeMessageAddOn(
+      IdentifiedUser user,
+      ChangeNotes changeNotes,
+      PatchSet patchSet,
+      Map<String, Short> oldApprovals,
+      Map<String, Short> approvals) {
+    return Optional.empty();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 575a19d..58321e9 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
@@ -30,6 +29,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
@@ -177,6 +177,7 @@
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
   private final PluginSetContext<CommentValidator> commentValidators;
+  private final PluginSetContext<OnPostReview> onPostReviews;
   private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
   private final boolean strictLabels;
   private final boolean publishPatchSetLevelComment;
@@ -203,6 +204,7 @@
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
       PluginSetContext<CommentValidator> commentValidators,
+      PluginSetContext<OnPostReview> onPostReviews,
       ReplyAttentionSetUpdates replyAttentionSetUpdates) {
     this.updateFactory = updateFactory;
     this.changeResourceFactory = changeResourceFactory;
@@ -223,6 +225,7 @@
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
     this.commentValidators = commentValidators;
+    this.onPostReviews = onPostReviews;
     this.replyAttentionSetUpdates = replyAttentionSetUpdates;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
     this.publishPatchSetLevelComment =
@@ -353,7 +356,8 @@
       }
 
       // Add WorkInProgressOp if requested.
-      if (input.ready || input.workInProgress) {
+      if ((input.ready || input.workInProgress)
+          && didWorkInProgressChange(revision.getChange().isWorkInProgress(), input)) {
         if (input.ready && input.workInProgress) {
           output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
           return Response.withStatusCode(SC_BAD_REQUEST, output);
@@ -402,6 +406,10 @@
     return Response.ok(output);
   }
 
+  private boolean didWorkInProgressChange(boolean currentWorkInProgress, ReviewInput input) {
+    return input.ready == currentWorkInProgress || input.workInProgress != currentWorkInProgress;
+  }
+
   private NotifyHandling defaultNotify(Change c, ReviewInput in) {
     boolean workInProgress = c.isWorkInProgress();
     if (in.workInProgress) {
@@ -1225,9 +1233,10 @@
     }
 
     private boolean isReviewer(ChangeContext ctx) {
-      ChangeData cd = changeDataFactory.create(ctx.getNotes());
-      ReviewerSet reviewers = cd.reviewers();
-      return reviewers.byState(REVIEWER).contains(ctx.getAccountId());
+      return approvalsUtil
+          .getReviewers(ctx.getNotes())
+          .byState(REVIEWER)
+          .contains(ctx.getAccountId());
     }
 
     private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
@@ -1269,7 +1278,10 @@
             del.add(c);
             update.putApproval(normName, (short) 0);
           }
-        } else if (c != null && c.value() != ent.getValue()) {
+          // Only allow voting again if the vote is copied over from a past patch-set, or the
+          // values are different.
+        } else if (c != null
+            && (c.value() != ent.getValue() || isApprovalCopiedOver(c, ctx.getNotes()))) {
           PatchSetApproval.Builder b =
               c.toBuilder()
                   .value(ent.getValue())
@@ -1312,6 +1324,17 @@
       return !del.isEmpty() || !ups.isEmpty();
     }
 
+    /**
+     * Approval is copied over if it doesn't exist in the approvals of the current patch-set
+     * according to change notes (which means it was computed in {@link
+     * com.google.gerrit.server.ApprovalInference})
+     */
+    private boolean isApprovalCopiedOver(
+        PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
+      return !changeNotes.getApprovals().get(changeNotes.getChange().currentPatchSetId()).stream()
+          .anyMatch(p -> p.equals(patchSetApproval));
+    }
+
     private void validatePostSubmitLabels(
         ChangeContext ctx,
         LabelTypes labelTypes,
@@ -1356,7 +1379,6 @@
         if (prev == null) {
           continue;
         }
-        checkState(prev != psa.value()); // Should be filtered out above.
         if (prev > psa.value()) {
           reduced.add(psa);
         }
@@ -1425,6 +1447,23 @@
       } else if (in.ready) {
         buf.append("\n\n" + START_REVIEW_MESSAGE);
       }
+
+      List<String> pluginMessages = new ArrayList<>();
+      onPostReviews.runEach(
+          onPostReview ->
+              onPostReview
+                  .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
+                  .ifPresent(
+                      pluginMessage ->
+                          pluginMessages.add(
+                              !pluginMessage.endsWith("\n")
+                                  ? pluginMessage + "\n"
+                                  : pluginMessage)));
+      if (!pluginMessages.isEmpty()) {
+        buf.append("\n\n");
+        buf.append(Joiner.on("\n").join(pluginMessages));
+      }
+
       if (buf.length() == 0) {
         return false;
       }
diff --git a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
index ed6c0a5..4acf809 100644
--- a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
+++ b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
@@ -42,7 +42,6 @@
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.Collection;
@@ -56,7 +55,6 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.kohsuke.args4j.Option;
 
-@Singleton
 public class PreviewSubmit implements RestReadView<RevisionResource> {
   private static final int MAX_DEFAULT_BUNDLE_SIZE = 100 * 1024 * 1024;
 
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index 37318d0..1ed7fd7 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -34,7 +33,6 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
-import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -72,7 +70,6 @@
   private final PatchSetUtil psUtil;
   private final NotifyResolver notifyResolver;
   private final ProjectCache projectCache;
-  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   PutMessage(
@@ -84,8 +81,7 @@
       @GerritPersonIdent PersonIdent gerritIdent,
       PatchSetUtil psUtil,
       NotifyResolver notifyResolver,
-      ProjectCache projectCache,
-      DynamicItem<UrlFormatter> urlFormatter) {
+      ProjectCache projectCache) {
     this.updateFactory = updateFactory;
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
@@ -95,7 +91,6 @@
     this.psUtil = psUtil;
     this.notifyResolver = notifyResolver;
     this.projectCache = projectCache;
-    this.urlFormatter = urlFormatter;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/PutTopic.java b/java/com/google/gerrit/server/restapi/change/PutTopic.java
index 325b80c..3031781 100644
--- a/java/com/google/gerrit/server/restapi/change/PutTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/PutTopic.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetTopicOp;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -60,7 +61,7 @@
       sanitizedInput.topic = sanitizedInput.topic.trim();
     }
 
-    SetTopicOp op = topicOpFactory.create(sanitizedInput);
+    SetTopicOp op = topicOpFactory.create(sanitizedInput.topic);
     try (BatchUpdate u =
         updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
       u.addOp(req.getId(), op);
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 7df74f8..cbbaa52 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -190,9 +190,7 @@
     int cnt = queries.size();
     List<QueryResult<ChangeData>> results = queryProcessor.query(qb.parse(queries));
     List<List<ChangeInfo>> res =
-        json.create(
-                options, queryProcessor.getAttributesFactory(), queryProcessor.getInfosFactory())
-            .format(results);
+        json.create(options, queryProcessor.getInfosFactory()).format(results);
     for (int n = 0; n < cnt; n++) {
       List<ChangeInfo> info = res.get(n);
       if (results.get(n).more() && !info.isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 75ba4c1..cfdf04d 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -115,7 +116,7 @@
     try (Repository repo = repoManager.openRepository(change.getProject());
         ObjectInserter oi = repo.newObjectInserter();
         ObjectReader reader = oi.newReader();
-        RevWalk rw = new RevWalk(reader);
+        RevWalk rw = CodeReviewCommit.newRevWalk(reader);
         BatchUpdate bu =
             updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       if (!change.isNew()) {
@@ -124,18 +125,23 @@
         throw new ResourceConflictException(
             "cannot rebase merge commits or commit with no ancestor");
       }
-      // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
-      bu.setNotify(NotifyResolver.Result.none());
-      bu.setRepository(repo, rw, oi);
-      bu.addOp(
-          change.getId(),
+      RebaseChangeOp rebaseOp =
           rebaseFactory
               .create(rsrc.getNotes(), rsrc.getPatchSet(), findBaseRev(repo, rw, rsrc, input))
               .setForceContentMerge(true)
-              .setFireRevisionCreated(true));
+              .setAllowConflicts(input.allowConflicts)
+              .setFireRevisionCreated(true);
+      // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
+      bu.setNotify(NotifyResolver.Result.none());
+      bu.setRepository(repo, rw, oi);
+      bu.addOp(change.getId(), rebaseOp);
       bu.execute();
+
+      ChangeInfo changeInfo = json.create(OPTIONS).format(change.getProject(), change.getId());
+      changeInfo.containsGitConflicts =
+          !rebaseOp.getRebasedCommit().getFilesWithGitConflicts().isEmpty() ? true : null;
+      return Response.ok(changeInfo);
     }
-    return Response.ok(json.create(OPTIONS).format(change.getProject(), change.getId()));
   }
 
   private ObjectId findBaseRev(
diff --git a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
index c4dd04e..7fe463e 100644
--- a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -69,13 +70,9 @@
     }
     input.user = Strings.nullToEmpty(input.user).trim();
     if (!input.user.isEmpty()) {
-      Account.Id attentionUserId = null;
-      try {
-        attentionUserId = accountResolver.resolve(input.user).asUnique().account().id();
-      } catch (AccountResolver.UnresolvableAccountException ex) {
-        throw new BadRequestException(
-            "The user specified in the input body couldn't be found.", ex);
-      }
+      Account.Id attentionUserId =
+          AttentionSetUtil.resolveAccount(
+              accountResolver, attentionResource.getChangeResource().getNotes(), input.user);
       if (attentionUserId.get() != attentionResource.getAccountId().get()) {
         throw new BadRequestException(
             "The field \"user\" must be empty, or must match the user specified in the URL.");
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 65c0cda..a1bd678 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -312,10 +312,14 @@
       throws BadRequestException, IOException, PermissionBackendException,
           UnprocessableEntityException, ConfigInvalidException {
     AttentionSetUtil.validateInput(add);
-    Account.Id attentionUserId =
-        getAccountIdAndValidateUser(changeNotes, add.user, accountsChangedInCommit);
-
-    addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
+    try {
+      Account.Id attentionUserId =
+          getAccountIdAndValidateUser(changeNotes, add.user, accountsChangedInCommit);
+      addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
+    } catch (AccountResolver.UnresolvableAccountException ex) {
+      // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
+      // message here, then it would be possible to probe whether an account exists.
+    }
   }
 
   private void removeFromAttentionSet(
@@ -326,10 +330,14 @@
       throws BadRequestException, IOException, PermissionBackendException,
           UnprocessableEntityException, ConfigInvalidException {
     AttentionSetUtil.validateInput(remove);
-    Account.Id attentionUserId =
-        getAccountIdAndValidateUser(changeNotes, remove.user, accountsChangedInCommit);
-
-    removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
+    try {
+      Account.Id attentionUserId =
+          getAccountIdAndValidateUser(changeNotes, remove.user, accountsChangedInCommit);
+      removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
+    } catch (AccountResolver.UnresolvableAccountException ex) {
+      // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
+      // message here, then it would be possible to probe whether an account exists.
+    }
   }
 
   private Account.Id getAccountId(ChangeNotes changeNotes, String user)
@@ -356,15 +364,21 @@
       ChangeNotes changeNotes, String user, Set<Account.Id> accountsChangedInCommit)
       throws ConfigInvalidException, IOException, PermissionBackendException,
           UnprocessableEntityException, BadRequestException {
-    Account.Id attentionUserId = getAccountId(changeNotes, user);
-    if (accountsChangedInCommit.contains(attentionUserId)) {
-      throw new BadRequestException(
-          String.format(
-              "%s can not be added/removed twice, and can not be added and "
-                  + "removed at the same time",
-              user));
+    try {
+      Account.Id attentionUserId = getAccountId(changeNotes, user);
+      if (accountsChangedInCommit.contains(attentionUserId)) {
+        throw new BadRequestException(
+            String.format(
+                "%s can not be added/removed twice, and can not be added and "
+                    + "removed at the same time",
+                user));
+      }
+      accountsChangedInCommit.add(attentionUserId);
+      return attentionUserId;
+    } catch (AccountResolver.UnresolvableAccountException ex) {
+      // This can only happen if this user can't see the account or the account doesn't exist.
+      // Silently modify the account's attention set anyway, if the account exists.
+      return accountResolver.resolveIgnoreVisibility(user).asUnique().account().id();
     }
-    accountsChangedInCommit.add(attentionUserId);
-    return attentionUserId;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index 39df82d..d80ab696 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -182,7 +182,7 @@
     // Sort results
     Stream<Map.Entry<Account.Id, MutableDouble>> sorted =
         reviewerScores.entrySet().stream()
-            .sorted(Collections.reverseOrder(Map.Entry.comparingByValue()));
+            .sorted(Map.Entry.comparingByValue(Collections.reverseOrder()));
     List<Account.Id> sortedSuggestions = sorted.map(Map.Entry::getKey).collect(toList());
     logger.atFine().log("Sorted suggestions: %s", sortedSuggestions);
     return sortedSuggestions;
@@ -202,7 +202,7 @@
       double baseWeight, String query, List<Account.Id> candidateList)
       throws IOException, ConfigInvalidException {
     int numberOfRelevantChanges = config.getInt("suggest", "relevantChanges", 50);
-    // Get the user's last 25 changes, check reviewers
+    // Get the user's last numberOfRelevantChanges changes, check reviewers
     try {
       List<ChangeData> result =
           queryProvider
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index e77bfe7..ba0720a 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -30,8 +30,10 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -66,12 +68,14 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
@@ -108,7 +112,6 @@
 
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
-  private final ChangeData.Factory changeDataFactory;
   private final Provider<MergeOp> mergeOpProvider;
   private final Provider<MergeSuperSet> mergeSuperSet;
   private final AccountResolver accountResolver;
@@ -127,7 +130,6 @@
   Submit(
       GitRepositoryManager repoManager,
       PermissionBackend permissionBackend,
-      ChangeData.Factory changeDataFactory,
       Provider<MergeOp> mergeOpProvider,
       Provider<MergeSuperSet> mergeSuperSet,
       AccountResolver accountResolver,
@@ -137,7 +139,6 @@
       ProjectCache projectCache) {
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
-    this.changeDataFactory = changeDataFactory;
     this.mergeOpProvider = mergeOpProvider;
     this.mergeSuperSet = mergeSuperSet;
     this.accountResolver = accountResolver;
@@ -307,7 +308,7 @@
       throw new StorageException("Could not determine problems for the change", e);
     }
 
-    ChangeData cd = changeDataFactory.create(resource.getNotes());
+    ChangeData cd = resource.getChangeResource().getChangeData();
     try {
       MergeOp.checkSubmitRule(cd, false);
     } catch (ResourceConflictException e) {
@@ -373,8 +374,15 @@
 
   public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws IOException {
     Set<ChangeData> mergeabilityMap = new HashSet<>();
+    Set<ObjectId> outDatedPatchsets = new HashSet<>();
     for (ChangeData change : cs.changes()) {
       mergeabilityMap.add(change);
+      // Add all the patchsets commit ids except the current patchset.
+      outDatedPatchsets.addAll(
+          change.notes().getPatchSets().values().stream()
+              .map(p -> p.commitId())
+              .collect(Collectors.toSet()));
+      outDatedPatchsets.remove(change.currentPatchSet().commitId());
     }
 
     ListMultimap<BranchNameKey, ChangeData> cbb = cs.changesByBranch();
@@ -388,12 +396,17 @@
           allParents.add(parent.getId());
         }
       }
-
       for (ChangeData change : targetBranch) {
+
         RevCommit commit = commits.get(change.getId());
         boolean isMergeCommit = commit.getParentCount() > 1;
         boolean isLastInChain = !allParents.contains(commit.getId());
-
+        if (Arrays.stream(commit.getParents()).anyMatch(c -> outDatedPatchsets.contains(c.getId()))
+            && !isCherryPickSubmit(change)) {
+          // Found a parent that depends on an outdated patchset and the submit strategy is not
+          // cherry-pick.
+          continue;
+        }
         // Recheck mergeability rather than using value stored in the index,
         // which may be stale.
         // TODO(dborowitz): This is ugly; consider providing a way to not read
@@ -419,6 +432,11 @@
     return mergeabilityMap;
   }
 
+  private boolean isCherryPickSubmit(ChangeData changeData) {
+    SubmitTypeRecord submitTypeRecord = changeData.submitTypeRecord();
+    return submitTypeRecord.isOk() && submitTypeRecord.type == SubmitType.CHERRY_PICK;
+  }
+
   private HashMap<Change.Id, RevCommit> findCommits(
       Collection<ChangeData> changes, Project.NameKey project) throws IOException {
     HashMap<Change.Id, RevCommit> commits = new HashMap<>();
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 780c60a..c3dd1b5 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -222,7 +222,6 @@
     info.showAssigneeInChangesTable =
         toBoolean(
             config.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
-    info.largeChange = config.getInt("change", "largeChange", 500);
     info.replyTooltip =
         Optional.ofNullable(config.getString("change", null, "replyTooltip"))
                 .orElse("Reply and score")
@@ -305,6 +304,7 @@
     info.editGpgKeys =
         toBoolean(enableSignedPush && config.getBoolean("gerrit", null, "editGpgKeys", true));
     info.primaryWeblinkName = config.getString("gerrit", null, "primaryWeblinkName");
+    info.instanceId = config.getString("gerrit", null, "instanceId");
     return info;
   }
 
@@ -320,17 +320,12 @@
     PluginConfigInfo info = new PluginConfigInfo();
     info.hasAvatars = toBoolean(avatar.hasImplementation());
     info.jsResourcePaths = new ArrayList<>();
-    info.htmlResourcePaths = new ArrayList<>();
     plugins.runEach(
         plugin -> {
           String path =
               String.format(
                   "plugins/%s/%s", plugin.getPluginName(), plugin.getJavaScriptResourcePath());
-          if (path.endsWith(".html")) {
-            info.htmlResourcePaths.add(path);
-          } else {
-            info.jsResourcePaths.add(path);
-          }
+          info.jsResourcePaths.add(path);
         });
     return info;
   }
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index 74ca721..0ec63ba 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -45,7 +46,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.group.db.GroupsUpdate;
@@ -54,6 +54,7 @@
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.GroupCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -66,6 +67,7 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Optional;
+import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -75,7 +77,7 @@
 public class CreateGroup
     implements RestCollectionCreateView<TopLevelResource, GroupResource, GroupInput> {
   private final Provider<IdentifiedUser> self;
-  private final PersonIdent serverIdent;
+  private final TimeZone serverTimeZone;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final GroupCache groupCache;
   private final GroupResolver groups;
@@ -89,7 +91,7 @@
   @Inject
   CreateGroup(
       Provider<IdentifiedUser> self,
-      @GerritPersonIdent PersonIdent serverIdent,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
       @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
       GroupCache groupCache,
       GroupResolver groups,
@@ -100,7 +102,7 @@
       @GerritServerConfig Config cfg,
       Sequences sequences) {
     this.self = self;
-    this.serverIdent = serverIdent;
+    this.serverTimeZone = serverIdent.get().getTimeZone();
     this.groupsUpdateProvider = groupsUpdateProvider;
     this.groupCache = groupCache;
     this.groups = groups;
@@ -210,7 +212,7 @@
             createGroupArgs.uuid,
             GroupUuid.make(
                 createGroupArgs.getGroupName(),
-                self.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone())));
+                self.get().newCommitterIdent(TimeUtil.nowTs(), serverTimeZone)));
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
             .setGroupUUID(uuid)
diff --git a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
index 8a469f1..e3aa0f3 100644
--- a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
+++ b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
@@ -14,12 +14,14 @@
 
 package com.google.gerrit.server.restapi.group;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.util.Comparator.comparing;
 
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroupByIdAudit;
 import com.google.gerrit.entities.AccountGroupMemberAudit;
 import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupAuditEventInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
@@ -33,7 +35,6 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.GroupResource;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -42,7 +43,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Optional;
+import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Repository;
 
@@ -105,14 +106,17 @@
                   member));
         }
       }
-
-      for (AccountGroupByIdAudit auditEvent :
-          groups.getSubgroupsAudit(allUsersRepo, group.getGroupUUID())) {
+      List<AccountGroupByIdAudit> subGroupsAudit =
+          groups.getSubgroupsAudit(allUsersRepo, group.getGroupUUID());
+      Map<AccountGroup.UUID, InternalGroup> groups =
+          groupCache.get(
+              subGroupsAudit.stream().map(a -> a.includeUuid()).collect(toImmutableList()));
+      for (AccountGroupByIdAudit auditEvent : subGroupsAudit) {
         AccountGroup.UUID includedGroupUUID = auditEvent.includeUuid();
-        Optional<InternalGroup> includedGroup = groupCache.get(includedGroupUUID);
+        InternalGroup includedGroup = groups.get(includedGroupUUID);
         GroupInfo member;
-        if (includedGroup.isPresent()) {
-          member = groupJson.format(new InternalGroupDescription(includedGroup.get()));
+        if (includedGroup != null) {
+          member = groupJson.format(new InternalGroupDescription(includedGroup));
         } else {
           member = new GroupInfo();
           member.id = Url.encode(includedGroupUUID.get());
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index 3e2a577..96402be 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.restapi.group;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
@@ -57,7 +57,6 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
-import java.util.Optional;
 import java.util.Set;
 import java.util.SortedMap;
 import java.util.TreeMap;
@@ -273,10 +272,12 @@
       throws IOException, ConfigInvalidException, PermissionBackendException {
     Pattern pattern = getRegexPattern();
     Stream<GroupDescription.Internal> existingGroups =
-        getAllExistingGroups()
-            .filter(group -> isRelevant(pattern, group))
-            .map(this::loadGroup)
-            .flatMap(Streams::stream)
+        loadGroups(
+                getAllExistingGroups()
+                    .filter(group -> isRelevant(pattern, group))
+                    .map(g -> g.getUUID())
+                    .collect(toImmutableSet()))
+            .stream()
             .filter(this::isVisible)
             .sorted(GROUP_COMPARATOR)
             .skip(start);
@@ -359,11 +360,13 @@
       throws IOException, ConfigInvalidException, PermissionBackendException {
     Pattern pattern = getRegexPattern();
     Stream<? extends GroupDescription.Internal> foundGroups =
-        groups
-            .getAllGroupReferences()
-            .filter(group -> isRelevant(pattern, group))
-            .map(this::loadGroup)
-            .flatMap(Streams::stream)
+        loadGroups(
+                groups
+                    .getAllGroupReferences()
+                    .filter(group -> isRelevant(pattern, group))
+                    .map(g -> g.getUUID())
+                    .collect(toImmutableSet()))
+            .stream()
             .filter(this::isVisible)
             .filter(filter)
             .sorted(GROUP_COMPARATOR)
@@ -379,8 +382,10 @@
     return groupInfos;
   }
 
-  private Optional<GroupDescription.Internal> loadGroup(GroupReference groupReference) {
-    return groupCache.get(groupReference.getUUID()).map(InternalGroupDescription::new);
+  private Set<GroupDescription.Internal> loadGroups(Collection<AccountGroup.UUID> groupUuids) {
+    return groupCache.get(groupUuids).values().stream()
+        .map(InternalGroupDescription::new)
+        .collect(toImmutableSet());
   }
 
   private List<GroupInfo> getGroupsOwnedBy(String id)
diff --git a/java/com/google/gerrit/server/restapi/group/ListMembers.java b/java/com/google/gerrit/server/restapi/group/ListMembers.java
index 87b00c1..22870c1 100644
--- a/java/com/google/gerrit/server/restapi/group/ListMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/ListMembers.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -31,7 +32,6 @@
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
@@ -157,23 +157,21 @@
   private Set<Account.Id> getIndirectMemberIds(
       GroupDescription.Internal group, HashSet<AccountGroup.UUID> seenGroups) {
     Set<Account.Id> indirectMembers = new HashSet<>();
+    Set<AccountGroup.UUID> subgroupMembersToLoad = new HashSet<>();
     for (AccountGroup.UUID subgroupUuid : group.getSubgroups()) {
       if (!seenGroups.contains(subgroupUuid)) {
         seenGroups.add(subgroupUuid);
-
-        Set<Account.Id> subgroupMembers =
-            groupCache
-                .get(subgroupUuid)
-                .map(InternalGroupDescription::new)
-                .map(
-                    subgroup -> {
-                      GroupControl subgroupControl = groupControlFactory.controlFor(subgroup);
-                      return getTransitiveMemberIds(subgroup, subgroupControl, seenGroups);
-                    })
-                .orElseGet(ImmutableSet::of);
-        indirectMembers.addAll(subgroupMembers);
+        subgroupMembersToLoad.add(subgroupUuid);
       }
     }
+    groupCache.get(subgroupMembersToLoad).values().stream()
+        .map(InternalGroupDescription::new)
+        .forEach(
+            subgroup -> {
+              GroupControl subgroupControl = groupControlFactory.controlFor(subgroup);
+              indirectMembers.addAll(getTransitiveMemberIds(subgroup, subgroupControl, seenGroups));
+            });
+
     return indirectMembers;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/group/QueryGroups.java b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
index 380d42e..26e8459 100644
--- a/java/com/google/gerrit/server/restapi/group/QueryGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.GroupInfo;
@@ -26,7 +27,6 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryResult;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.group.GroupQueryBuilder;
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 037a953..37616cd 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2018 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
 import static com.google.gerrit.entities.RefNames.REFS_HEADS;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
@@ -25,9 +26,10 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.permissions.DefaultPermissionMappings;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -36,15 +38,14 @@
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
 
-@Singleton
-public class CheckAccess implements RestModifyView<ProjectResource, AccessCheckInput> {
+public class CheckAccess implements RestReadView<ProjectResource> {
   private final AccountResolver accountResolver;
   private final PermissionBackend permissionBackend;
   private final GitRepositoryManager gitRepositoryManager;
@@ -59,7 +60,15 @@
     this.gitRepositoryManager = gitRepositoryManager;
   }
 
-  @Override
+  @Option(name = "--ref", usage = "ref name to check permission for")
+  String refName;
+
+  @Option(name = "--account", usage = "account to check acccess for")
+  String account;
+
+  @Option(name = "--perm", usage = "permission to check; default: read of any ref.")
+  String permission;
+
   public Response<AccessCheckInfo> apply(ProjectResource rsrc, AccessCheckInput input)
       throws PermissionBackendException, RestApiException, IOException, ConfigInvalidException {
     permissionBackend.user(rsrc.getUser()).check(GlobalPermission.VIEW_ACCESS);
@@ -73,60 +82,90 @@
       throw new BadRequestException("input requires 'account'");
     }
 
-    Account.Id match = accountResolver.resolve(input.account).asUnique().account().id();
+    try (TraceContext traceContext = TraceContext.open()) {
+      traceContext.enableAclLogging();
 
-    AccessCheckInfo info = new AccessCheckInfo();
-    try {
-      permissionBackend
-          .absentUser(match)
-          .project(rsrc.getNameKey())
-          .check(ProjectPermission.ACCESS);
-    } catch (AuthException e) {
-      info.message = String.format("user %s cannot see project %s", match, rsrc.getName());
-      info.status = HttpServletResponse.SC_FORBIDDEN;
-      return Response.ok(info);
-    }
+      Account.Id match = accountResolver.resolve(input.account).asUnique().account().id();
 
-    RefPermission refPerm;
-    if (!Strings.isNullOrEmpty(input.permission)) {
-      if (Strings.isNullOrEmpty(input.ref)) {
-        throw new BadRequestException("must set 'ref' when specifying 'permission'");
-      }
-      Optional<RefPermission> rp = DefaultPermissionMappings.refPermission(input.permission);
-      if (!rp.isPresent()) {
-        throw new BadRequestException(
-            String.format("'%s' is not recognized as ref permission", input.permission));
-      }
-
-      refPerm = rp.get();
-    } else {
-      refPerm = RefPermission.READ;
-    }
-
-    if (!Strings.isNullOrEmpty(input.ref)) {
       try {
         permissionBackend
             .absentUser(match)
-            .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
-            .check(refPerm);
+            .project(rsrc.getNameKey())
+            .check(ProjectPermission.ACCESS);
       } catch (AuthException e) {
-        info.status = HttpServletResponse.SC_FORBIDDEN;
-        info.message =
-            String.format(
-                "user %s lacks permission %s for %s in project %s",
-                match, input.permission, input.ref, rsrc.getName());
-        return Response.ok(info);
+        return Response.ok(
+            createInfo(
+                traceContext,
+                HttpServletResponse.SC_FORBIDDEN,
+                String.format("user %s cannot see project %s", match, rsrc.getName())));
       }
-    } else {
-      // We say access is okay if there are no refs, but this warrants a warning,
-      // as access denied looks the same as no branches to the user.
-      try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) {
-        if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) {
-          info.message = "access is OK, but repository has no branches under refs/heads/";
+
+      RefPermission refPerm;
+      if (!Strings.isNullOrEmpty(input.permission)) {
+        if (Strings.isNullOrEmpty(input.ref)) {
+          throw new BadRequestException("must set 'ref' when specifying 'permission'");
+        }
+        Optional<RefPermission> rp = DefaultPermissionMappings.refPermission(input.permission);
+        if (!rp.isPresent()) {
+          throw new BadRequestException(
+              String.format("'%s' is not recognized as ref permission", input.permission));
+        }
+
+        refPerm = rp.get();
+      } else {
+        refPerm = RefPermission.READ;
+      }
+
+      String message = null;
+      if (!Strings.isNullOrEmpty(input.ref)) {
+        try {
+          permissionBackend
+              .absentUser(match)
+              .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
+              .check(refPerm);
+        } catch (AuthException e) {
+          return Response.ok(
+              createInfo(
+                  traceContext,
+                  HttpServletResponse.SC_FORBIDDEN,
+                  String.format(
+                      "user %s lacks permission %s for %s in project %s",
+                      match, input.permission, input.ref, rsrc.getName())));
+        }
+      } else {
+        // We say access is okay if there are no refs, but this warrants a warning,
+        // as access denied looks the same as no branches to the user.
+        try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) {
+          if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) {
+            message = "access is OK, but repository has no branches under refs/heads/";
+          }
         }
       }
+      return Response.ok(createInfo(traceContext, HttpServletResponse.SC_OK, message));
     }
-    info.status = HttpServletResponse.SC_OK;
-    return Response.ok(info);
+  }
+
+  private AccessCheckInfo createInfo(TraceContext traceContext, int statusCode, String message) {
+    AccessCheckInfo info = new AccessCheckInfo();
+    info.status = statusCode;
+    info.message = message;
+    info.debugLogs = traceContext.getAclLogRecords();
+    if (info.debugLogs.isEmpty()) {
+      info.debugLogs =
+          ImmutableList.of("Found no rules that apply, so defaulting to no permission");
+    }
+    return info;
+  }
+
+  @Override
+  public Response<AccessCheckInfo> apply(ProjectResource rsrc)
+      throws PermissionBackendException, RestApiException, IOException, ConfigInvalidException {
+
+    AccessCheckInput input = new AccessCheckInput();
+    input.ref = refName;
+    input.account = account;
+    input.permission = permission;
+
+    return apply(rsrc, input);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java b/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java
deleted file mode 100644
index 6aaa678..0000000
--- a/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.project;
-
-import com.google.gerrit.extensions.api.config.AccessCheckInfo;
-import com.google.gerrit.extensions.api.config.AccessCheckInput;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectResource;
-import com.google.inject.Inject;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.kohsuke.args4j.Option;
-
-public class CheckAccessReadView implements RestReadView<ProjectResource> {
-  String refName;
-  String account;
-  String permission;
-
-  @Inject CheckAccess checkAccess;
-
-  @Option(name = "--ref", usage = "ref name to check permission for")
-  void addOption(String refName) {
-    this.refName = refName;
-  }
-
-  @Option(name = "--account", usage = "account to check acccess for")
-  void setAccount(String account) {
-    this.account = account;
-  }
-
-  @Option(name = "--perm", usage = "permission to check; default: read of any ref.")
-  void setPermission(String perm) {
-    this.permission = perm;
-  }
-
-  @Override
-  public Response<AccessCheckInfo> apply(ProjectResource rsrc)
-      throws PermissionBackendException, RestApiException, IOException, ConfigInvalidException {
-
-    AccessCheckInput input = new AccessCheckInput();
-    input.ref = refName;
-    input.account = account;
-    input.permission = permission;
-
-    return checkAccess.apply(rsrc, input);
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/project/CheckMergeability.java b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
index da6ff14..4730318 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
@@ -101,10 +101,20 @@
       }
 
       RevCommit targetCommit = rw.parseCommit(destRef.getObjectId());
-      RevCommit sourceCommit = MergeUtil.resolveCommit(git, rw, source);
 
-      if (!commits.canRead(resource.getProjectState(), git, sourceCommit)) {
-        throw new BadRequestException("do not have read permission for: " + source);
+      RevCommit sourceCommit = null;
+      try {
+        sourceCommit = MergeUtil.resolveCommit(git, rw, source);
+        if (!commits.canRead(resource.getProjectState(), git, sourceCommit)) {
+          throw new BadRequestException("do not have read permission for: " + source);
+        }
+      } catch (BadRequestException e) {
+        // Throw a unified exception for permission denied and unresolvable commits.
+        throw new BadRequestException(
+            "Error resolving: '"
+                + source
+                + "'. Do not have read permission, or failed to resolve to a commit.",
+            e);
       }
 
       if (rw.isMergedInto(sourceCommit, targetCommit)) {
diff --git a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
index 21d7f0b..033463c 100644
--- a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -48,10 +49,12 @@
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/** The collection of commit IDs (ie. 40 char hex IDs) */
 @Singleton
 public class CommitsCollection implements ChildCollection<ProjectResource, CommitResource> {
   private final DynamicMap<RestView<CommitResource>> views;
@@ -93,10 +96,12 @@
     try (Repository repo = repoManager.openRepository(parent.getNameKey());
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(objectId);
-      rw.parseBody(commit);
       if (!canRead(parent.getProjectState(), repo, commit)) {
         throw new ResourceNotFoundException(id);
       }
+      // GetCommit depends on the body of both the commit and parent being parsed, to get the
+      // subject.
+      rw.parseBody(commit);
       for (int i = 0; i < commit.getParentCount(); i++) {
         rw.parseBody(rw.parseCommit(commit.getParent(i)));
       }
@@ -171,9 +176,8 @@
     // If we have already checked change refs using the change index, spare any further checks for
     // changes.
     List<Ref> refs =
-        repo.getRefDatabase().getRefs().stream()
-            .filter(r -> !r.getName().startsWith(RefNames.REFS_CHANGES))
-            .collect(toImmutableList());
+        repo.getRefDatabase()
+            .getRefsByPrefixWithExclusions(RefDatabase.ALL, ImmutableSet.of(RefNames.REFS_CHANGES));
     return reachable.fromRefs(project, repo, commit, refs);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java b/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
similarity index 77%
rename from java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
rename to java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
index 783b39b..904a16f 100644
--- a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
+++ b/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
@@ -21,6 +21,10 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInfo.ConfigParameterInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInfo.InheritedBooleanInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInfo.MaxObjectSizeLimitInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInfo.SubmitTypeInfo;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -42,9 +46,12 @@
 import java.util.Map;
 import java.util.TreeMap;
 
-public class ConfigInfoImpl extends ConfigInfo {
+public class ConfigInfoCreator {
+  /** do not instantiate this class. */
+  private ConfigInfoCreator() {}
+
   @SuppressWarnings("deprecation")
-  public ConfigInfoImpl(
+  public static ConfigInfo constructInfo(
       boolean serverEnableSignedPush,
       ProjectState projectState,
       CurrentUser user,
@@ -53,8 +60,9 @@
       AllProjectsName allProjects,
       UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views) {
+    ConfigInfo configInfo = new ConfigInfo();
     Project p = projectState.getProject();
-    this.description = Strings.emptyToNull(p.getDescription());
+    configInfo.description = Strings.emptyToNull(p.getDescription());
 
     ProjectState parentState = Iterables.getFirst(projectState.parents(), null);
     for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
@@ -63,48 +71,51 @@
       if (parentState != null) {
         info.inheritedValue = parentState.is(cfg);
       }
-      BooleanProjectConfigTransformations.set(cfg, this, info);
+      BooleanProjectConfigTransformations.set(cfg, configInfo, info);
     }
 
     if (!serverEnableSignedPush) {
-      this.enableSignedPush = null;
-      this.requireSignedPush = null;
+      configInfo.enableSignedPush = null;
+      configInfo.requireSignedPush = null;
     }
 
-    this.maxObjectSizeLimit = getMaxObjectSizeLimit(projectState, p);
+    configInfo.maxObjectSizeLimit = getMaxObjectSizeLimit(projectState, p);
 
-    this.defaultSubmitType = new SubmitTypeInfo();
-    this.defaultSubmitType.value = projectState.getSubmitType();
-    this.defaultSubmitType.configuredValue =
+    configInfo.defaultSubmitType = new SubmitTypeInfo();
+    configInfo.defaultSubmitType.value = projectState.getSubmitType();
+    configInfo.defaultSubmitType.configuredValue =
         MoreObjects.firstNonNull(
             projectState.getConfig().getProject().getSubmitType(), Project.DEFAULT_SUBMIT_TYPE);
     ProjectState parent =
         projectState.isAllProjects() ? projectState : projectState.parents().get(0);
-    this.defaultSubmitType.inheritedValue = parent.getSubmitType();
+    configInfo.defaultSubmitType.inheritedValue = parent.getSubmitType();
 
-    this.submitType = this.defaultSubmitType.value;
+    configInfo.submitType = configInfo.defaultSubmitType.value;
 
-    this.state =
+    configInfo.state =
         p.getState() != com.google.gerrit.extensions.client.ProjectState.ACTIVE
             ? p.getState()
             : null;
 
-    this.commentlinks = new LinkedHashMap<>();
+    configInfo.commentlinks = new LinkedHashMap<>();
     for (CommentLinkInfo cl : projectState.getCommentLinks()) {
-      this.commentlinks.put(cl.name, cl);
+      configInfo.commentlinks.put(cl.name, cl);
     }
 
-    pluginConfig = getPluginConfig(projectState, pluginConfigEntries, cfgFactory, allProjects);
+    configInfo.pluginConfig =
+        getPluginConfig(projectState, pluginConfigEntries, cfgFactory, allProjects);
 
-    actions = new TreeMap<>();
+    configInfo.actions = new TreeMap<>();
     for (UiAction.Description d : uiActions.from(views, new ProjectResource(projectState, user))) {
-      actions.put(d.getId(), new ActionInfo(d));
+      configInfo.actions.put(d.getId(), new ActionInfo(d));
     }
 
-    this.extensionPanelNames = projectState.getConfig().getExtensionPanelSections();
+    configInfo.extensionPanelNames = projectState.getConfig().getExtensionPanelSections();
+    return configInfo;
   }
 
-  private MaxObjectSizeLimitInfo getMaxObjectSizeLimit(ProjectState projectState, Project p) {
+  private static MaxObjectSizeLimitInfo getMaxObjectSizeLimit(
+      ProjectState projectState, Project p) {
     MaxObjectSizeLimitInfo info = new MaxObjectSizeLimitInfo();
     EffectiveMaxObjectSizeLimit limit = projectState.getEffectiveMaxObjectSizeLimit();
     long value = limit.value;
@@ -114,7 +125,7 @@
     return info;
   }
 
-  private Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
+  private static Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
       ProjectState project,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
@@ -162,7 +173,7 @@
     return !pluginConfig.isEmpty() ? pluginConfig : null;
   }
 
-  private String getInheritedValue(
+  private static String getInheritedValue(
       ProjectState project, PluginConfigFactory cfgFactory, Extension<ProjectConfigEntry> e) {
     ProjectConfigEntry configEntry = e.getProvider().get();
     ProjectState parent = Iterables.getFirst(project.parents(), null);
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index b901057..2fd2d65 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -109,6 +109,12 @@
               + MagicBranch.getMagicRefNamePrefix(ref)
               + "\"");
     }
+    if (!isBranchAllowed(ref)) {
+      throw new BadRequestException(
+          "Cannot create a branch with name \""
+              + ref
+              + "\". Not allowed to create branches under Gerrit internal or tags refs.");
+    }
 
     BranchNameKey name = BranchNameKey.create(rsrc.getNameKey(), ref);
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
@@ -187,4 +193,9 @@
       throw new BadRequestException("invalid revision \"" + input.revision + "\"", e);
     }
   }
+
+  /** Branches cannot be created under any Gerrit internal or tags refs. */
+  private boolean isBranchAllowed(String branch) {
+    return !RefNames.isGerritRef(branch) && !branch.startsWith(RefNames.REFS_TAGS);
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateChange.java b/java/com/google/gerrit/server/restapi/project/CreateChange.java
index dbcd8c9..59efd06 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateChange.java
@@ -59,8 +59,8 @@
       throw new AuthException("Authentication required");
     }
 
-    if (!Strings.isNullOrEmpty(input.project)) {
-      throw new BadRequestException("may not specify project");
+    if (!Strings.isNullOrEmpty(input.project) && !rsrc.getName().equals(input.project)) {
+      throw new BadRequestException("project must match URL");
     }
 
     input.project = rsrc.getName();
diff --git a/java/com/google/gerrit/server/restapi/project/CreateDashboard.java b/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
index e9a0d7f..34d6696 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
@@ -26,11 +26,9 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import org.kohsuke.args4j.Option;
 
-@Singleton
 public class CreateDashboard
     implements RestCollectionCreateView<ProjectResource, DashboardResource, SetDashboardInput> {
   private final Provider<SetDefaultDashboard> setDefault;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index e93c25c..025ff50 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -174,6 +174,11 @@
       labelType.setCopyMaxScore(input.copyMaxScore);
     }
 
+    if (input.copyAllScoresIfListOfFilesDidNotChange != null) {
+      labelType.setCopyAllScoresIfListOfFilesDidNotChange(
+          input.copyAllScoresIfListOfFilesDidNotChange);
+    }
+
     if (input.copyAllScoresIfNoChange != null) {
       labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index a5a0034..f3b2bad 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.ProjectOwnerGroupsProvider;
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -60,6 +61,7 @@
 import java.util.List;
 import java.util.concurrent.locks.Lock;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Repository;
 
@@ -79,6 +81,8 @@
   private final PluginItemContext<ProjectNameLockManager> lockManager;
   private final ProjectCreator projectCreator;
 
+  private final Config gerritConfig;
+
   @Inject
   CreateProject(
       ProjectCreator projectCreator,
@@ -90,7 +94,8 @@
       Provider<PutConfig> putConfig,
       AllProjectsName allProjects,
       AllUsersName allUsers,
-      PluginItemContext<ProjectNameLockManager> lockManager) {
+      PluginItemContext<ProjectNameLockManager> lockManager,
+      @GerritServerConfig Config gerritConfig) {
     this.projectsCollection = projectsCollection;
     this.projectCreator = projectCreator;
     this.groupResolver = groupResolver;
@@ -101,6 +106,7 @@
     this.allProjects = allProjects;
     this.allUsers = allUsers;
     this.lockManager = lockManager;
+    this.gerritConfig = gerritConfig;
   }
 
   @Override
@@ -190,22 +196,47 @@
 
   private List<String> normalizeBranchNames(List<String> branches) throws BadRequestException {
     if (branches == null || branches.isEmpty()) {
-      return Collections.singletonList(Constants.R_HEADS + Constants.MASTER);
+      // Use host-level default for HEAD or fall back to 'master' if nothing else was specified in
+      // the input.
+      String defaultBranch = gerritConfig.getString("gerrit", null, "defaultBranch");
+      defaultBranch =
+          defaultBranch != null
+              ? normalizeAndValidateBranch(defaultBranch)
+              : Constants.R_HEADS + Constants.MASTER;
+      return Collections.singletonList(defaultBranch);
     }
-
     List<String> normalizedBranches = new ArrayList<>();
     for (String branch : branches) {
-      while (branch.startsWith("/")) {
-        branch = branch.substring(1);
-      }
-      branch = RefNames.fullName(branch);
-      if (!Repository.isValidRefName(branch)) {
-        throw new BadRequestException(String.format("Branch \"%s\" is not a valid name.", branch));
-      }
+      branch = normalizeAndValidateBranch(branch);
       if (!normalizedBranches.contains(branch)) {
         normalizedBranches.add(branch);
       }
     }
     return normalizedBranches;
   }
+
+  private String normalizeAndValidateBranch(String branch) throws BadRequestException {
+    while (branch.startsWith("/")) {
+      branch = branch.substring(1);
+    }
+    branch = RefNames.fullName(branch);
+    if (!Repository.isValidRefName(branch)) {
+      throw new BadRequestException(String.format("Branch \"%s\" is not a valid name.", branch));
+    }
+    return branch;
+  }
+
+  static class ValidBranchListener implements ProjectCreationValidationListener {
+    @Override
+    public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+      for (String branch : args.branch) {
+        if (RefNames.isGerritRef(branch)) {
+          throw new ValidationException(
+              String.format(
+                  "Cannot create a project with branch %s. Branches in the Gerrit internal refs namespace are not allowed",
+                  branch));
+        }
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index 5cfb118..b552ff5 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -102,6 +102,8 @@
     try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
       ObjectId revid = RefUtil.parseBaseRevision(repo, resource.getNameKey(), input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
+      // Reachability through tags does not influence a commit's visibility, so no need to check for
+      // visibility.
       RevObject object = rw.parseAny(revid);
       rw.reset();
       boolean isAnnotated = Strings.emptyToNull(input.message) != null;
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteRef.java b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
index 2395bdd..4e13ba9 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteRef.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
@@ -160,7 +160,7 @@
    *
    * @param projectState the {@code ProjectState} of the project whose refs are to be deleted.
    * @param refsToDelete the refs to be deleted.
-   * @param prefix the prefix of the refs.
+   * @param prefix the prefix to add to abbreviated refs, eg. "refs/heads/".
    * @throws IOException
    * @throws ResourceConflictException
    * @throws PermissionBackendException
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTag.java b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
index 545b752..8d0a3d3 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteTag.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
@@ -14,10 +14,8 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.gerrit.entities.RefNames.isConfigRef;
-
+import com.google.common.base.Preconditions;
 import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -27,6 +25,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import org.eclipse.jgit.lib.Constants;
 
 @Singleton
 public class DeleteTag implements RestModifyView<TagResource, Input> {
@@ -43,10 +42,7 @@
       throws RestApiException, IOException, PermissionBackendException {
     String tag = RefUtil.normalizeTagRef(resource.getTagInfo().ref);
 
-    if (isConfigRef(tag)) {
-      // Never allow to delete the meta config branch.
-      throw new MethodNotAllowedException("not allowed to delete " + tag);
-    }
+    Preconditions.checkState(tag.startsWith(Constants.R_TAGS));
 
     deleteRef.deleteSingleRef(resource.getProjectState(), tag);
     return Response.none();
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTags.java b/java/com/google/gerrit/server/restapi/project/DeleteTags.java
index 6e8ec37..7ac3aff 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteTags.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTags.java
@@ -43,6 +43,10 @@
     if (input == null || input.tags == null || input.tags.isEmpty()) {
       throw new BadRequestException("tags must be specified");
     }
+
+    // If input.tags = ["refs/heads/bla"], this will actually delete the 'ref/heads/bla' branch,
+    // rather than refs/tags/refs/heads/bla.
+    // Since this is checked against DELETE permissions for refs/heads/bla, we'll let it go through.
     deleteRef.deleteMultipleRefs(
         project.getProjectState(), ImmutableSet.copyOf(input.tags), R_TAGS);
     return Response.none();
diff --git a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
index 0d5ab88..7bee2f2 100644
--- a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.entities.Patch;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -27,7 +26,6 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.change.FileInfoJson;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.CommitResource;
 import com.google.gerrit.server.project.FileResource;
@@ -39,6 +37,10 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.kohsuke.args4j.Option;
 
+/**
+ * like {@link FilesCollection}, but for commits that are specified as hex ID, rather than branch
+ * names.
+ */
 @Singleton
 public class FilesInCommitCollection implements ChildCollection<CommitResource, FileResource> {
   private final DynamicMap<RestView<FileResource>> views;
@@ -85,21 +87,18 @@
       this.fileInfoJson = fileInfoJson;
     }
 
+    public ListFiles setParent(int parentNum) {
+      this.parentNum = parentNum;
+      return this;
+    }
+
     @Override
     public Response<Map<String, FileInfo>> apply(CommitResource resource)
         throws ResourceConflictException, PatchListNotAvailableException {
       RevCommit commit = resource.getCommit();
-      PatchListKey key;
-
-      if (parentNum > 0) {
-        key =
-            PatchListKey.againstParentNum(
-                parentNum, commit, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
-      } else {
-        key = PatchListKey.againstCommit(null, commit, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
-      }
-
-      return Response.ok(fileInfoJson.toFileInfoMap(resource.getProjectState().getNameKey(), key));
+      return Response.ok(
+          fileInfoJson.getFileInfoMap(
+              resource.getProjectState().getNameKey(), commit, parentNum - 1));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetConfig.java b/java/com/google/gerrit/server/restapi/project/GetConfig.java
index ad66587..8ffd5ec 100644
--- a/java/com/google/gerrit/server/restapi/project/GetConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/GetConfig.java
@@ -67,7 +67,7 @@
             .project(resource.getNameKey())
             .test(ProjectPermission.READ_CONFIG);
     return Response.ok(
-        new ConfigInfoImpl(
+        ConfigInfoCreator.constructInfo(
             serverEnableSignedPush,
             resource.getProjectState(),
             resource.getUser(),
diff --git a/java/com/google/gerrit/server/restapi/project/ListBranches.java b/java/com/google/gerrit/server/restapi/project/ListBranches.java
index fecdc8e..2c26933 100644
--- a/java/com/google/gerrit/server/restapi/project/ListBranches.java
+++ b/java/com/google/gerrit/server/restapi/project/ListBranches.java
@@ -143,7 +143,11 @@
   BranchInfo toBranchInfo(BranchResource rsrc)
       throws IOException, ResourceNotFoundException, PermissionBackendException {
     try (Repository db = repoManager.openRepository(rsrc.getNameKey())) {
-      Ref r = db.exactRef(rsrc.getRef());
+      String refName = rsrc.getRef();
+      if (RefNames.isRefsUsersSelf(refName, rsrc.getProjectState().isAllUsers())) {
+        refName = RefNames.refsUsers(rsrc.getUser().getAccountId());
+      }
+      Ref r = db.exactRef(refName);
       if (r == null) {
         throw new ResourceNotFoundException();
       }
diff --git a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
index 0bd053e..fd18c8d 100644
--- a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
@@ -23,9 +23,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.ChildProjects;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
@@ -41,16 +39,11 @@
   @Option(name = "--limit", usage = "maximum number of parents projects to list")
   private int limit;
 
-  private final PermissionBackend permissionBackend;
   private final ChildProjects childProjects;
   private final Provider<QueryProjects> queryProvider;
 
   @Inject
-  ListChildProjects(
-      PermissionBackend permissionBackend,
-      ChildProjects childProjects,
-      Provider<QueryProjects> queryProvider) {
-    this.permissionBackend = permissionBackend;
+  ListChildProjects(ChildProjects childProjects, Provider<QueryProjects> queryProvider) {
     this.childProjects = childProjects;
     this.queryProvider = queryProvider;
   }
@@ -83,10 +76,7 @@
   }
 
   private List<ProjectInfo> directChildProjects(Project.NameKey parent) throws RestApiException {
-    PermissionBackend.WithUser currentUser = permissionBackend.currentUser();
     return queryProvider.get().withQuery("parent:" + parent.get()).withLimit(limit).apply().stream()
-        .filter(
-            p -> currentUser.project(Project.nameKey(p.name)).testOrFalse(ProjectPermission.ACCESS))
         .collect(toList());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index 5418876..c4ae33a 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -90,7 +90,11 @@
 import org.eclipse.jgit.lib.Repository;
 import org.kohsuke.args4j.Option;
 
-/** List projects visible to the calling user. */
+/**
+ * List projects visible to the calling user.
+ *
+ * <p>Implement {@code GET /projects/}, without a {@code query=} parameter.
+ */
 public class ListProjects implements RestReadView<TopLevelResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -486,7 +490,7 @@
                 continue;
               }
 
-              List<Ref> refs = retieveBranchRefs(e);
+              List<Ref> refs = retrieveBranchRefs(e);
               if (!hasValidRef(refs)) {
                 continue;
               }
@@ -574,7 +578,7 @@
     }
   }
 
-  private List<Ref> retieveBranchRefs(ProjectState e) throws PermissionBackendException {
+  private List<Ref> retrieveBranchRefs(ProjectState e) throws PermissionBackendException {
     boolean canReadAllRefs = e.statePermitsRead();
     if (canReadAllRefs) {
       try {
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index ee3914d..9217077 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -29,8 +29,10 @@
 import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.project.RefValidationHelper;
 import com.google.gerrit.server.restapi.change.CherryPickCommit;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 
 public class Module extends RestApiModule {
+
   @Override
   protected void configure() {
     bind(ProjectsCollection.class);
@@ -46,6 +48,8 @@
     DynamicMap.mapOf(binder(), LABEL_KIND);
 
     DynamicSet.bind(binder(), GerritConfigListener.class).to(SetParent.class);
+    DynamicSet.bind(binder(), ProjectCreationValidationListener.class)
+        .to(CreateProject.ValidBranchListener.class);
 
     create(PROJECT_KIND).to(CreateProject.class);
     put(PROJECT_KIND).to(PutProject.class);
@@ -57,7 +61,7 @@
     get(PROJECT_KIND, "access").to(GetAccess.class);
     post(PROJECT_KIND, "access").to(SetAccess.class);
     put(PROJECT_KIND, "access:review").to(CreateAccessChange.class);
-    get(PROJECT_KIND, "check.access").to(CheckAccessReadView.class);
+    get(PROJECT_KIND, "check.access").to(CheckAccess.class);
 
     post(PROJECT_KIND, "check").to(Check.class);
 
@@ -79,10 +83,7 @@
 
     put(PROJECT_KIND, "ban").to(BanCommit.class);
 
-    get(PROJECT_KIND, "statistics.git").to(GetStatistics.class);
-    post(PROJECT_KIND, "gc").to(GarbageCollect.class);
     post(PROJECT_KIND, "index").to(Index.class);
-    post(PROJECT_KIND, "index.changes").to(IndexChanges.class);
 
     child(PROJECT_KIND, "branches").to(BranchesCollection.class);
     create(BRANCH_KIND).to(CreateBranch.class);
@@ -121,4 +122,14 @@
 
     factory(ProjectNode.Factory.class);
   }
+
+  /** Separately bind batch functionality. */
+  public static class BatchModule extends RestApiModule {
+    @Override
+    protected void configure() {
+      get(PROJECT_KIND, "statistics.git").to(GetStatistics.class);
+      post(PROJECT_KIND, "gc").to(GarbageCollect.class);
+      post(PROJECT_KIND, "index.changes").to(IndexChanges.class);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
index f92624f..efc739c 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
@@ -41,11 +41,9 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
 
-@Singleton
 public class ProjectsCollection
     implements RestCollection<TopLevelResource, ProjectResource>, NeedsParams {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index 3bf432b..0770bda 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -176,7 +176,7 @@
       }
 
       ProjectState state = projectStateFactory.create(projectConfigFactory.read(md).getCacheable());
-      return new ConfigInfoImpl(
+      return ConfigInfoCreator.constructInfo(
           serverEnableSignedPush,
           state,
           user.get(),
diff --git a/java/com/google/gerrit/server/restapi/project/QueryProjects.java b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
index e4f7df5..a9d818d 100644
--- a/java/com/google/gerrit/server/restapi/project/QueryProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
@@ -36,6 +36,7 @@
 import java.util.List;
 import org.kohsuke.args4j.Option;
 
+/** Implements the {@code GET /projects/?query=QUERY} endpoint. */
 public class QueryProjects implements RestReadView<TopLevelResource> {
   private final ProjectIndexCollection indexes;
   private final ProjectQueryBuilder queryBuilder;
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index 1e04dc4..34b6812 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -189,6 +189,12 @@
       dirty = true;
     }
 
+    if (input.copyAllScoresIfListOfFilesDidNotChange != null) {
+      labelTypeBuilder.setCopyAllScoresIfListOfFilesDidNotChange(
+          input.copyAllScoresIfListOfFilesDidNotChange);
+      dirty = true;
+    }
+
     if (input.copyAllScoresIfNoChange != null) {
       labelTypeBuilder.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
       dirty = true;
diff --git a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
index 4592100..e670dc2 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.rules;
 
+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;
 
@@ -22,7 +23,6 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.project.ProjectCache;
@@ -76,32 +76,17 @@
     SubmitRecord submitRecord = new SubmitRecord();
     submitRecord.status = SubmitRecord.Status.OK;
 
-    List<LabelType> labelTypes;
-    List<PatchSetApproval> approvals;
-    try {
-      labelTypes = cd.getLabelTypes().getLabelTypes();
-      approvals = cd.currentApprovals();
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Unable to fetch labels and approvals for change %s", cd.getId());
-
-      submitRecord.errorMessage = "Unable to fetch labels and approvals for the change";
-      submitRecord.status = SubmitRecord.Status.RULE_ERROR;
-      return Optional.of(submitRecord);
-    }
-
+    List<LabelType> labelTypes = cd.getLabelTypes().getLabelTypes();
+    List<PatchSetApproval> approvals = cd.currentApprovals();
     submitRecord.labels = new ArrayList<>(labelTypes.size());
 
     for (LabelType t : labelTypes) {
       LabelFunction labelFunction = t.getFunction();
-      if (labelFunction == null) {
-        logger.atSevere().log(
-            "Unable to find the LabelFunction for label %s, change %s", t.getName(), cd.getId());
-
-        submitRecord.errorMessage = "Unable to find the LabelFunction for label " + t.getName();
-        submitRecord.status = SubmitRecord.Status.RULE_ERROR;
-        return Optional.of(submitRecord);
-      }
+      checkState(
+          labelFunction != null,
+          "Unable to find the LabelFunction for label %s, change %s",
+          t.getName(),
+          cd.getId());
 
       Collection<PatchSetApproval> approvalsForLabel = getApprovalsForLabel(approvals, t);
       SubmitRecord.Label label = labelFunction.check(t, approvalsForLabel);
diff --git a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
index b2bfbd5..08335df 100644
--- a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
+++ b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
@@ -17,14 +17,12 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
@@ -40,11 +38,6 @@
  */
 @Singleton
 public class IgnoreSelfApprovalRule implements SubmitRule {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-  private static final String E_UNABLE_TO_FETCH_UPLOADER = "Unable to fetch uploader";
-  private static final String E_UNABLE_TO_FETCH_LABELS =
-      "Unable to fetch labels and approvals for the change";
-
   public static class Module extends AbstractModule {
     @Override
     public void configure() {
@@ -56,16 +49,8 @@
 
   @Override
   public Optional<SubmitRecord> evaluate(ChangeData cd) {
-    List<LabelType> labelTypes;
-    List<PatchSetApproval> approvals;
-    try {
-      labelTypes = cd.getLabelTypes().getLabelTypes();
-      approvals = cd.currentApprovals();
-    } catch (StorageException e) {
-      logger.atWarning().withCause(e).log(E_UNABLE_TO_FETCH_LABELS);
-      return ruleError(E_UNABLE_TO_FETCH_LABELS);
-    }
-
+    List<LabelType> labelTypes = cd.getLabelTypes().getLabelTypes();
+    List<PatchSetApproval> approvals = cd.currentApprovals();
     boolean shouldIgnoreSelfApproval =
         labelTypes.stream().anyMatch(LabelType::isIgnoreSelfApproval);
     if (!shouldIgnoreSelfApproval) {
@@ -73,14 +58,7 @@
       return Optional.empty();
     }
 
-    Account.Id uploader;
-    try {
-      uploader = cd.currentPatchSet().uploader();
-    } catch (StorageException e) {
-      logger.atWarning().withCause(e).log(E_UNABLE_TO_FETCH_UPLOADER);
-      return ruleError(E_UNABLE_TO_FETCH_UPLOADER);
-    }
-
+    Account.Id uploader = cd.currentPatchSet().uploader();
     SubmitRecord submitRecord = new SubmitRecord();
     submitRecord.status = SubmitRecord.Status.OK;
     submitRecord.labels = new ArrayList<>(labelTypes.size());
@@ -112,7 +90,7 @@
         // Add an additional requirement to be more descriptive on why the label counts as not
         // approved.
         submitRecord.requirements.add(
-            SubmitRequirement.builder()
+            LegacySubmitRequirement.builder()
                 .setFallbackText("Approval from non-uploader required")
                 .setType("non_uploader_approval")
                 .build());
@@ -140,13 +118,6 @@
     return false;
   }
 
-  private static Optional<SubmitRecord> ruleError(String reason) {
-    SubmitRecord submitRecord = new SubmitRecord();
-    submitRecord.errorMessage = reason;
-    submitRecord.status = SubmitRecord.Status.RULE_ERROR;
-    return Optional.of(submitRecord);
-  }
-
   @VisibleForTesting
   static Collection<PatchSetApproval> filterOutPositiveApprovalsOfUser(
       Collection<PatchSetApproval> approvals, Account.Id user) {
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 57c4832..95e0829 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -16,9 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static com.google.gerrit.server.project.SubmitRuleEvaluator.createRuleError;
-import static com.google.gerrit.server.project.SubmitRuleEvaluator.defaultRuleError;
-import static com.google.gerrit.server.project.SubmitRuleEvaluator.defaultTypeError;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
@@ -61,6 +58,9 @@
  */
 public class PrologRuleEvaluator {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String DEFAULT_MSG = "Error evaluating project rules, check server log";
+
   /**
    * List of characters to allow in the label name, when an invalid name is used. Dash is allowed as
    * it can't be the first character: we use a prefix.
@@ -289,6 +289,13 @@
     return VALID_LABEL_MATCHER.retainFrom(name);
   }
 
+  private static SubmitRecord createRuleError(String err) {
+    SubmitRecord rec = new SubmitRecord();
+    rec.status = SubmitRecord.Status.RULE_ERROR;
+    rec.errorMessage = err;
+    return rec;
+  }
+
   private SubmitRecord invalidResult(Term rule, Term record, String reason) {
     return ruleError(
         String.format(
@@ -311,7 +318,7 @@
   private SubmitRecord ruleError(String err, Exception e) {
     if (opts.logErrors()) {
       logger.atSevere().withCause(e).log(err);
-      return defaultRuleError();
+      return createRuleError(DEFAULT_MSG);
     }
     return createRuleError(err);
   }
@@ -389,7 +396,6 @@
   private SubmitTypeRecord typeError(String err, Exception e) {
     if (opts.logErrors()) {
       logger.atSevere().withCause(e).log(err);
-      return defaultTypeError();
     }
     return SubmitTypeRecord.error(err);
   }
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 6faaec5..9907b1c 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -176,14 +176,16 @@
           grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
         });
 
+    config.upsertAccessSection(
+        "refs/meta/version",
+        version -> {
+          grant(config, version, Permission.READ, anonymous);
+        });
+
     grant(config, heads, codeReviewLabel, -1, 1, registered);
     grant(config, heads, Permission.FORGE_AUTHOR, registered);
-
-    config.upsertAccessSection(
-        "refs/*",
-        all -> {
-          grant(config, all, Permission.REVERT, registered);
-        });
+    grant(config, heads, Permission.READ, anonymous);
+    grant(config, heads, Permission.REVERT, registered);
 
     config.upsertAccessSection(
         "refs/for/" + AccessSection.ALL,
@@ -213,7 +215,7 @@
     config.upsertAccessSection(
         AccessSection.ALL,
         all -> {
-          grant(config, all, Permission.READ, adminsGroup, anonymous);
+          grant(config, all, Permission.READ, adminsGroup);
         });
 
     config.upsertAccessSection(
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
index 90973fb..3588860 100644
--- a/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
@@ -83,7 +84,8 @@
   @UsedAt(UsedAt.Project.GOOGLE)
   public AllUsersCreator setCodeReviewLabel(LabelType labelType) {
     checkArgument(
-        labelType.getName().equals("Code-Review"), "label should have 'Code-Review' as its name");
+        labelType.getName().equals(LabelId.CODE_REVIEW),
+        "label should have 'Code-Review' as its name");
     this.codeReviewLabel = labelType;
     return this;
   }
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index afa9d1a..bda3dc4 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -18,17 +18,18 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.GroupUuid;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.AuditLogFormatter;
 import com.google.gerrit.server.group.db.GroupConfig;
 import com.google.gerrit.server.group.db.GroupNameNotes;
@@ -91,7 +92,7 @@
   @Override
   public void create() throws IOException, ConfigInvalidException {
     GroupReference admins = createGroupReference("Administrators");
-    GroupReference serviceUsers = createGroupReference("Service Users");
+    GroupReference serviceUsers = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
 
     AllProjectsInput allProjectsInput =
         AllProjectsInput.builder()
diff --git a/java/com/google/gerrit/server/schema/Schema_184.java b/java/com/google/gerrit/server/schema/Schema_184.java
index d0ca3d0..85d4740 100644
--- a/java/com/google/gerrit/server/schema/Schema_184.java
+++ b/java/com/google/gerrit/server/schema/Schema_184.java
@@ -17,11 +17,12 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.AuditLogFormatter;
 import com.google.gerrit.server.group.db.GroupConfig;
 import com.google.gerrit.server.group.db.GroupNameNotes;
@@ -44,7 +45,7 @@
   @Override
   public void upgrade(Arguments args, UpdateUI ui) throws Exception {
     try (Repository allUsersRepo = args.repoManager.openRepository(args.allUsers)) {
-      AccountGroup.NameKey newName = AccountGroup.nameKey("Service Users");
+      AccountGroup.NameKey newName = AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS);
       Optional<GroupReference> nonInteractiveUsers =
           GroupNameNotes.loadAllGroups(allUsersRepo).stream()
               .filter(g -> g.getName().equals("Non-Interactive Users"))
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 5485192..39e3a59 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -54,14 +54,14 @@
       ImmutableList.of(
           "[access \"refs/*\"]",
           "  read = group Administrators",
-          "  read = group Anonymous Users",
-          "  revert = group Registered Users",
           "[access \"refs/for/*\"]",
           "  addPatchSet = group Registered Users",
           "[access \"refs/for/refs/*\"]",
           "  push = group Registered Users",
           "  pushMerge = group Registered Users",
           "[access \"refs/heads/*\"]",
+          "  read = group Anonymous Users",
+          "  revert = group Registered Users",
           "  create = group Administrators",
           "  create = group Project Owners",
           "  editTopicName = +force group Administrators",
@@ -88,6 +88,8 @@
           "  read = group Project Owners",
           "  submit = group Administrators",
           "  submit = group Project Owners",
+          "[access \"refs/meta/version\"]",
+          "  read = group Anonymous Users",
           "[access \"refs/tags/*\"]",
           "  create = group Administrators",
           "  create = group Project Owners",
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java b/java/com/google/gerrit/server/ssh/HostKey.java
similarity index 60%
copy from java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
copy to java/com/google/gerrit/server/ssh/HostKey.java
index 08d6ce7..9397612 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
+++ b/java/com/google/gerrit/server/ssh/HostKey.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2019 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,12 +12,22 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.change;
+package com.google.gerrit.server.ssh;
 
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.util.List;
+public class HostKey {
+  private final String host;
+  private final byte[] key;
 
-public interface PluginDefinedAttributesFactory {
-  List<PluginDefinedInfo> create(ChangeData cd);
+  public HostKey(String host, byte[] key) {
+    this.host = host;
+    this.key = key;
+  }
+
+  public String getHost() {
+    return host;
+  }
+
+  public byte[] getKey() {
+    return key;
+  }
 }
diff --git a/java/com/google/gerrit/server/ssh/NoSshInfo.java b/java/com/google/gerrit/server/ssh/NoSshInfo.java
index 91a949b..a716398 100644
--- a/java/com/google/gerrit/server/ssh/NoSshInfo.java
+++ b/java/com/google/gerrit/server/ssh/NoSshInfo.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.ssh;
 
-import com.jcraft.jsch.HostKey;
 import java.util.Collections;
 import java.util.List;
 
diff --git a/java/com/google/gerrit/server/ssh/SshInfo.java b/java/com/google/gerrit/server/ssh/SshInfo.java
index 430846d..ec5a579 100644
--- a/java/com/google/gerrit/server/ssh/SshInfo.java
+++ b/java/com/google/gerrit/server/ssh/SshInfo.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.ssh;
 
-import com.jcraft.jsch.HostKey;
 import java.util.List;
 
 public interface SshInfo {
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index 4efa4c8..109c9c3 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -31,6 +31,7 @@
 import com.google.inject.Inject;
 import com.google.inject.OutOfScopeException;
 import com.google.inject.assistedinject.Assisted;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 
@@ -43,7 +44,8 @@
         Change change,
         Account.Id submitter,
         NotifyResolver.Result notify,
-        RepoView repoView);
+        RepoView repoView,
+        String stickyApprovalDiff);
   }
 
   private final ExecutorService sendEmailsExecutor;
@@ -57,6 +59,7 @@
   private final Account.Id submitter;
   private final NotifyResolver.Result notify;
   private final RepoView repoView;
+  private final String stickyApprovalDiff;
 
   @Inject
   EmailMerge(
@@ -69,7 +72,8 @@
       @Assisted Change change,
       @Assisted @Nullable Account.Id submitter,
       @Assisted NotifyResolver.Result notify,
-      @Assisted RepoView repoView) {
+      @Assisted RepoView repoView,
+      @Assisted String stickyApprovalDiff) {
     this.sendEmailsExecutor = executor;
     this.mergedSenderFactory = mergedSenderFactory;
     this.requestContext = requestContext;
@@ -80,6 +84,7 @@
     this.submitter = submitter;
     this.notify = notify;
     this.repoView = repoView;
+    this.stickyApprovalDiff = stickyApprovalDiff;
   }
 
   void sendAsync() {
@@ -91,7 +96,8 @@
   public void run() {
     RequestContext old = requestContext.setContext(this);
     try {
-      MergedSender emailSender = mergedSenderFactory.create(project, change.getId());
+      MergedSender emailSender =
+          mergedSenderFactory.create(project, change.getId(), Optional.of(stickyApprovalDiff));
       if (submitter != null) {
         emailSender.setFrom(submitter);
       }
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index fdf3664..a8a8675 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -35,13 +35,15 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Change.Status;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitTypeRecord;
+import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -118,7 +120,7 @@
 
   private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS = SubmitRuleOptions.builder().build();
   private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_ALLOW_CLOSED =
-      SUBMIT_RULE_OPTIONS.toBuilder().allowClosed(true).build();
+      SUBMIT_RULE_OPTIONS.toBuilder().recomputeOnClosedChanges(true).build();
 
   public static class CommitStatus {
     private final ImmutableMap<Change.Id, ChangeData> changes;
@@ -187,7 +189,7 @@
       // date by this point.
       ChangeData cd = requireNonNull(changes.get(id), () -> String.format("ChangeData for %s", id));
       return requireNonNull(
-          cd.getSubmitRecords(submitRuleOptions(allowClosed)),
+          cd.submitRecords(submitRuleOptions(allowClosed)),
           "getSubmitRecord only valid after submit rules are evalutated");
     }
 
@@ -232,7 +234,7 @@
   private final SubmitStrategyFactory submitStrategyFactory;
   private final SubscriptionGraph.Factory subscriptionGraphFactory;
   private final SubmoduleCommits.Factory submoduleCommitsFactory;
-  private final SubmissionListener superprojectUpdateSubmissionListener;
+  private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners;
   private final Provider<MergeOpRepoManager> ormProvider;
   private final NotifyResolver notifyResolver;
   private final RetryHelper retryHelper;
@@ -264,7 +266,8 @@
       SubmitStrategyFactory submitStrategyFactory,
       SubmoduleCommits.Factory submoduleCommitsFactory,
       SubscriptionGraph.Factory subscriptionGraphFactory,
-      @SuperprojectUpdateOnSubmission SubmissionListener superprojectUpdateSubmissionListener,
+      @SuperprojectUpdateOnSubmission
+          ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners,
       Provider<MergeOpRepoManager> ormProvider,
       NotifyResolver notifyResolver,
       TopicMetrics topicMetrics,
@@ -279,7 +282,7 @@
     this.submitStrategyFactory = submitStrategyFactory;
     this.submoduleCommitsFactory = submoduleCommitsFactory;
     this.subscriptionGraphFactory = subscriptionGraphFactory;
-    this.superprojectUpdateSubmissionListener = superprojectUpdateSubmissionListener;
+    this.superprojectUpdateSubmissionListeners = superprojectUpdateSubmissionListeners;
     this.ormProvider = ormProvider;
     this.notifyResolver = notifyResolver;
     this.retryHelper = retryHelper;
@@ -388,8 +391,9 @@
     return Joiner.on("; ").join(labelResults);
   }
 
-  private static String describeSubmitRequirement(SubmitRequirement submitRequirement) {
-    return String.format("Submit requirement not fulfilled: %s", submitRequirement.fallbackText());
+  private static String describeSubmitRequirement(LegacySubmitRequirement legacySubmitRequirement) {
+    return String.format(
+        "Submit requirement not fulfilled: %s", legacySubmitRequirement.fallbackText());
   }
 
   private void checkSubmitRulesAndState(ChangeSet cs, boolean allowMerged)
@@ -491,18 +495,35 @@
         logger.atFine().log("Calculated to merge %s", indexBackedChangeSet);
 
         // Reload ChangeSet so that we don't rely on (potentially) stale index data for merging
-        ChangeSet cs = reloadChanges(indexBackedChangeSet);
+        ChangeSet noteDbChangeSet = reloadChanges(indexBackedChangeSet);
+
+        // At this point, any change that isn't new can be filtered out since they were only here
+        // in the first place due to stale index.
+        List<ChangeData> filteredChanges = new ArrayList<>();
+        for (ChangeData changeData : noteDbChangeSet.changes()) {
+          if (!changeData.change().getStatus().equals(Status.NEW)) {
+            logger.atFine().log(
+                "Change %s has status %s due to stale index, so it is skipped during submit",
+                changeData.getId().toString(), changeData.change().getStatus().name());
+            continue;
+          }
+          filteredChanges.add(changeData);
+        }
+
+        // There are no hidden changes (or else we would have thrown AuthException above).
+        ChangeSet filteredNoteDbChangeSet =
+            new ChangeSet(filteredChanges, /* hiddenChanges= */ ImmutableList.of());
 
         // Count cross-project submissions outside of the retry loop. The chance of a single project
         // failing increases with the number of projects, so the failure count would be inflated if
         // this metric were incremented inside of integrateIntoHistory.
-        int projects = cs.projects().size();
+        int projects = filteredNoteDbChangeSet.projects().size();
         if (projects > 1) {
           topicMetrics.topicSubmissions.increment();
         }
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListener);
+            new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListeners);
         RetryTracker retryTracker = new RetryTracker();
         retryHelper
             .changeUpdate(
@@ -515,22 +536,25 @@
                     this.ts = TimeUtil.nowTs();
                     openRepoManager();
                   }
-                  this.commitStatus = new CommitStatus(cs, isRetry);
+                  this.commitStatus = new CommitStatus(filteredNoteDbChangeSet, isRetry);
                   if (checkSubmitRules) {
                     logger.atFine().log("Checking submit rules and state");
-                    checkSubmitRulesAndState(cs, isRetry);
+                    checkSubmitRulesAndState(filteredNoteDbChangeSet, isRetry);
                   } else {
                     logger.atFine().log("Bypassing submit rules");
-                    bypassSubmitRules(cs, isRetry);
+                    bypassSubmitRules(filteredNoteDbChangeSet, isRetry);
                   }
-                  integrateIntoHistory(cs, submissionExecutor);
+                  integrateIntoHistory(filteredNoteDbChangeSet, submissionExecutor);
                   return null;
                 })
             .listener(retryTracker)
             // Up to the entire submit operation is retried, including possibly many projects.
             // Multiply the timeout by the number of projects we're actually attempting to
-            // submit.
-            .defaultTimeoutMultiplier(cs.projects().size())
+            // submit. Times 2 to retry more persistently, to increase success rate.
+            .defaultTimeoutMultiplier(filteredNoteDbChangeSet.projects().size() * 2)
+            // By default, we only retry lock failures. Here it's better to also retry unexpected
+            // runtime exceptions.
+            .retryOn(t -> t instanceof RuntimeException)
             .call();
         submissionExecutor.afterExecutions(orm);
 
@@ -672,7 +696,7 @@
       if (e.getCause() instanceof IntegrationConflictException) {
         throw (IntegrationConflictException) e.getCause();
       }
-      throw new StorageException(genericMergeError(cs), e);
+      throw new InternalServerWithUserMessageException(genericMergeError(cs), e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 93c78a8..67f2907 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -18,19 +18,16 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginContext;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -55,8 +52,6 @@
  * included.
  */
 public class MergeSuperSet {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final ChangeData.Factory changeDataFactory;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Provider<MergeOpRepoManager> repoManagerProvider;
@@ -64,7 +59,6 @@
   private final PermissionBackend permissionBackend;
   private final Config cfg;
   private final ProjectCache projectCache;
-  private final ChangeNotes.Factory notesFactory;
 
   private MergeOpRepoManager orm;
   private boolean closeOrm;
@@ -77,8 +71,7 @@
       Provider<MergeOpRepoManager> repoManagerProvider,
       DynamicItem<MergeSuperSetComputation> mergeSuperSetComputation,
       PermissionBackend permissionBackend,
-      ProjectCache projectCache,
-      ChangeNotes.Factory notesFactory) {
+      ProjectCache projectCache) {
     this.cfg = cfg;
     this.changeDataFactory = changeDataFactory;
     this.queryProvider = queryProvider;
@@ -86,7 +79,6 @@
     this.mergeSuperSetComputation = mergeSuperSetComputation;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
-    this.notesFactory = notesFactory;
   }
 
   public static boolean wholeTopicEnabled(Config config) {
@@ -212,24 +204,8 @@
     if (!projectCache.get(cd.project()).map(ProjectState::statePermitsRead).orElse(false)) {
       return false;
     }
-
-    ChangeNotes notes;
     try {
-      notes = cd.notes();
-    } catch (NoSuchChangeException e) {
-      // The change was found in the index but is missing in NoteDb.
-      // This can happen in systems with multiple primary nodes when the replication of the index
-      // documents is faster than the replication of the Git data.
-      // Instead of failing, create the change notes from the index data so that the read permission
-      // check can be performed successfully.
-      logger.atWarning().log(
-          "Got change %d of project %s from index, but couldn't find it in NoteDb",
-          cd.getId().get(), cd.project().get());
-      notes = notesFactory.createFromIndexedChange(cd.change());
-    }
-
-    try {
-      permissionBackend.user(user).change(notes).check(ChangePermission.READ);
+      permissionBackend.user(user).change(cd).check(ChangePermission.READ);
       return true;
     } catch (AuthException e) {
       return false;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 21ff2fc..530c53f 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.patch.SubmitWithStickyApprovalDiff;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -119,6 +120,7 @@
     final Provider<InternalChangeQuery> queryProvider;
     final ProjectConfig.Factory projectConfigFactory;
     final SetPrivateOp.Factory setPrivateOpFactory;
+    final SubmitWithStickyApprovalDiff submitWithStickyApprovalDiff;
 
     final BranchNameKey destBranch;
     final CodeReviewRevWalk rw;
@@ -159,6 +161,7 @@
         Provider<InternalChangeQuery> queryProvider,
         ProjectConfig.Factory projectConfigFactory,
         SetPrivateOp.Factory setPrivateOpFactory,
+        SubmitWithStickyApprovalDiff submitWithStickyApprovalDiff,
         @Assisted BranchNameKey destBranch,
         @Assisted CommitStatus commitStatus,
         @Assisted CodeReviewRevWalk rw,
@@ -188,6 +191,7 @@
       this.tagCache = tagCache;
       this.queryProvider = queryProvider;
       this.setPrivateOpFactory = setPrivateOpFactory;
+      this.submitWithStickyApprovalDiff = submitWithStickyApprovalDiff;
 
       this.serverIdent = serverIdent;
       this.destBranch = destBranch;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index dfee2f8..3a04b82 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
@@ -41,6 +42,8 @@
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -73,6 +76,7 @@
   private Change updatedChange;
   private CodeReviewCommit alreadyMergedCommit;
   private boolean changeAlreadyMerged;
+  private String stickyApprovalDiff;
 
   protected SubmitStrategyOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
     this.args = args;
@@ -391,7 +395,9 @@
     }
   }
 
-  private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s) {
+  private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s)
+      throws AuthException, IOException, PermissionBackendException,
+          InvalidChangeOperationException {
     requireNonNull(s, "CommitMergeStatus may not be null");
     String txt = s.getDescription();
     if (s == CommitMergeStatus.CLEAN_MERGE) {
@@ -431,9 +437,16 @@
     }
   }
 
-  private ChangeMessage message(ChangeContext ctx, PatchSet.Id psId, String body) {
+  private ChangeMessage message(ChangeContext ctx, PatchSet.Id psId, String body)
+      throws AuthException, IOException, PermissionBackendException,
+          InvalidChangeOperationException {
+    stickyApprovalDiff = args.submitWithStickyApprovalDiff.apply(ctx.getNotes(), ctx.getUser());
     return ChangeMessagesUtil.newMessage(
-        psId, ctx.getUser(), ctx.getWhen(), body, ChangeMessagesUtil.TAG_MERGED);
+        psId,
+        ctx.getUser(),
+        ctx.getWhen(),
+        body + stickyApprovalDiff,
+        ChangeMessagesUtil.TAG_MERGED);
   }
 
   private void setMerged(ChangeContext ctx, ChangeMessage msg) {
@@ -441,7 +454,6 @@
     logger.atFine().log("Setting change %s merged", c.getId());
     c.setStatus(Change.Status.MERGED);
     c.setSubmissionId(args.submissionId.toString());
-
     // TODO(dborowitz): We need to be able to change the author of the message,
     // which is not the user from the update context. addMergedMessage was able
     // to do this in the past.
@@ -461,9 +473,12 @@
       // If we naively execute postUpdate even if the change is already merged when updateChange
       // being, then we are subject to a race where postUpdate steps are run twice if two submit
       // processes run at the same time.
-      logger.atFine().log("Skipping post-update steps for change %s", getId());
+      logger.atFine().log(
+          "Skipping post-update steps for change %s; submitter is %s", getId(), submitter);
       return;
     }
+    logger.atFine().log(
+        "Begin post-update steps for change %s; submitter is %s", getId(), submitter);
     postUpdateImpl(ctx);
 
     if (command != null) {
@@ -483,6 +498,9 @@
       }
     }
 
+    logger.atFine().log(
+        "Begin sending emails for submitting change %s; submitter is %s", getId(), submitter);
+
     // Assume the change must have been merged at this point, otherwise we would
     // have failed fast in one of the other steps.
     try {
@@ -492,7 +510,8 @@
               toMerge.change(),
               submitter.accountId(),
               ctx.getNotify(getId()),
-              ctx.getRepoView())
+              ctx.getRepoView(),
+              stickyApprovalDiff)
           .sendAsync();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", getId());
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index 2db625b..409c808 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -481,7 +481,7 @@
                   if (!traceContext.isTracing()) {
                     String traceId = "retry-on-failure-" + new RequestId();
                     traceContext.addTag(RequestId.Type.TRACE_ID, traceId).forceLogging();
-                    logger.atFine().withCause(t).log(
+                    logger.atWarning().withCause(t).log(
                         "AutoRetry: %s failed, retry with tracing enabled (cause = %s)",
                         actionName, cause);
                     opts.onAutoTrace().ifPresent(c -> c.accept(traceId));
@@ -492,7 +492,7 @@
                   // A non-recoverable failure occurred. We retried the operation with tracing
                   // enabled and it failed again. Log the failure so that admin can see if it
                   // differs from the failure that triggered the retry.
-                  logger.atFine().withCause(t).log(
+                  logger.atWarning().withCause(t).log(
                       "AutoRetry: auto-retry of %s has failed (cause = %s)", actionName, cause);
                   metrics.failuresOnAutoRetryCount.increment(actionType, actionName, cause);
                   return false;
@@ -504,7 +504,7 @@
       return executeWithTimeoutCount(actionType, action, opts, retryerBuilder.build(), listener);
     } finally {
       if (listener.getAttemptCount() > 1) {
-        logger.atFine().log("%s was attempted %d times", actionType, listener.getAttemptCount());
+        logger.atWarning().log("%s was attempted %d times", actionType, listener.getAttemptCount());
         metrics.attemptCounts.incrementBy(
             actionType,
             opts.actionName().orElse("N/A"),
diff --git a/java/com/google/gerrit/server/update/SubmissionExecutor.java b/java/com/google/gerrit/server/update/SubmissionExecutor.java
index 5a3a789..39eda58 100644
--- a/java/com/google/gerrit/server/update/SubmissionExecutor.java
+++ b/java/com/google/gerrit/server/update/SubmissionExecutor.java
@@ -17,7 +17,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.submit.MergeOpRepoManager;
-import java.util.Arrays;
 import java.util.Collection;
 import java.util.Optional;
 import java.util.stream.Collectors;
@@ -28,14 +27,9 @@
   private final boolean dryrun;
   private ImmutableList<BatchUpdateListener> additionalListeners = ImmutableList.of();
 
-  public SubmissionExecutor(
-      boolean dryrun, SubmissionListener listener, SubmissionListener... otherListeners) {
+  public SubmissionExecutor(boolean dryrun, ImmutableList<SubmissionListener> submissionListeners) {
     this.dryrun = dryrun;
-    this.submissionListeners =
-        ImmutableList.<SubmissionListener>builder()
-            .add(listener)
-            .addAll(Arrays.asList(otherListeners))
-            .build();
+    this.submissionListeners = submissionListeners;
     if (dryrun) {
       submissionListeners.forEach(SubmissionListener::setDryrun);
     }
diff --git a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
index dffdff0..4c65c80 100644
--- a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
+++ b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.submit.MergeOpRepoManager;
 import com.google.gerrit.server.submit.SubmoduleOp;
 import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
@@ -39,11 +40,11 @@
   private boolean dryrun;
 
   public static class Module extends AbstractModule {
-    @Override
-    protected void configure() {
-      bind(SubmissionListener.class)
-          .annotatedWith(SuperprojectUpdateOnSubmission.class)
-          .to(SuperprojectUpdateSubmissionListener.class);
+    @Provides
+    @SuperprojectUpdateOnSubmission
+    ImmutableList<SubmissionListener> provideSubmissionListeners(
+        SuperprojectUpdateSubmissionListener listener) {
+      return ImmutableList.of(listener);
     }
   }
 
diff --git a/java/com/google/gerrit/server/util/AttentionSetUtil.java b/java/com/google/gerrit/server/util/AttentionSetUtil.java
index 62cad3f..26c862d 100644
--- a/java/com/google/gerrit/server/util/AttentionSetUtil.java
+++ b/java/com/google/gerrit/server/util/AttentionSetUtil.java
@@ -16,11 +16,16 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import java.io.IOException;
 import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** Common helpers for dealing with attention set data structures. */
 public class AttentionSetUtil {
@@ -49,5 +54,45 @@
     }
   }
 
+  /**
+   * Returns the {@code Account.Id} of {@code user} if the user is active on the change, and exists.
+   * If the user doesn't exist or is not active on the change, the same exception is thrown to
+   * disallow probing for account existence based on exception type.
+   */
+  public static Account.Id resolveAccount(
+      AccountResolver accountResolver, ChangeNotes changeNotes, String user)
+      throws ConfigInvalidException, IOException, BadRequestException {
+    // We will throw this exception if the account doesn't exist, or if the account is not active.
+    // This is purposely the same exception so that users can't probe for account existence based on
+    // the thrown exception.
+    BadRequestException possibleExceptionForNotFoundOrInactiveAccount =
+        new BadRequestException(
+            String.format(
+                "%s doesn't exist or is not active on the change as an owner, uploader, "
+                    + "reviewer, or cc so they can't be added to the attention set",
+                user));
+    Account.Id attentionUserId;
+    try {
+      attentionUserId = accountResolver.resolveIgnoreVisibility(user).asUnique().account().id();
+    } catch (AccountResolver.UnresolvableAccountException ex) {
+      possibleExceptionForNotFoundOrInactiveAccount.initCause(ex);
+      throw possibleExceptionForNotFoundOrInactiveAccount;
+    }
+    if (!isActiveOnTheChange(changeNotes, attentionUserId)) {
+      throw possibleExceptionForNotFoundOrInactiveAccount;
+    }
+    return attentionUserId;
+  }
+
+  /**
+   * Returns whether {@code attentionUserId} is active on a change. Activity is defined as being a
+   * part of the reviewers, an uploader, or an owner of a change.
+   */
+  private static boolean isActiveOnTheChange(ChangeNotes changeNotes, Account.Id attentionUserId) {
+    return changeNotes.getChange().getOwner().equals(attentionUserId)
+        || changeNotes.getCurrentPatchSet().uploader().equals(attentionUserId)
+        || changeNotes.getReviewers().all().stream().anyMatch(id -> id.equals(attentionUserId));
+  }
+
   private AttentionSetUtil() {}
 }
diff --git a/java/com/google/gerrit/server/util/CommitMessageUtil.java b/java/com/google/gerrit/server/util/CommitMessageUtil.java
index 1c8ce0c..55e3951 100644
--- a/java/com/google/gerrit/server/util/CommitMessageUtil.java
+++ b/java/com/google/gerrit/server/util/CommitMessageUtil.java
@@ -22,13 +22,19 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.util.ChangeIdUtil;
 
 /** Utility functions to manipulate commit messages. */
 public class CommitMessageUtil {
   private static final SecureRandom rng;
+  private static final Pattern changeIdFooterPattern =
+      Pattern.compile("Change-Id: *(I[a-f0-9]{40})");
 
   static {
     try {
@@ -71,6 +77,31 @@
   }
 
   public static Change.Key generateKey() {
-    return Change.key("I" + generateChangeId().name());
+    return Change.key(getChangeIdFromObjectId(generateChangeId()));
+  }
+
+  public static String getChangeIdFromObjectId(ObjectId objectId) {
+    return "I" + objectId.name();
+  }
+
+  /**
+   * Return the value of Change-Id from the commit message footer.
+   *
+   * <p>The behaviour matches {@link org.eclipse.jgit.util.ChangeIdUtil}. If more than one matching
+   * Change-Id footer is found, return the value of the last one.
+   *
+   * @param commitMessage commit message to get Change-Id from.
+   * @return {@link Optional} value of Change-Id footer in the commit message.
+   */
+  public static Optional<String> getChangeIdFromCommitMessageFooter(String commitMessage) {
+    int indexOfChangeId = ChangeIdUtil.indexOfChangeId(commitMessage, "\n");
+    if (indexOfChangeId == -1) {
+      return Optional.empty();
+    }
+    Matcher matcher = changeIdFooterPattern.matcher(commitMessage);
+    if (matcher.find(indexOfChangeId)) {
+      return Optional.of(matcher.group(1));
+    }
+    return Optional.empty();
   }
 }
diff --git a/java/com/google/gerrit/server/util/PluginLogFile.java b/java/com/google/gerrit/server/util/PluginLogFile.java
index de8b3aa..345e1b3 100644
--- a/java/com/google/gerrit/server/util/PluginLogFile.java
+++ b/java/com/google/gerrit/server/util/PluginLogFile.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
-import org.apache.log4j.AsyncAppender;
 import org.apache.log4j.Layout;
 import org.apache.log4j.LogManager;
 import org.apache.log4j.Logger;
@@ -38,10 +37,14 @@
 
   @Override
   public void start() {
-    AsyncAppender asyncAppender = systemLog.createAsyncAppender(logName, layout, true, true);
     Logger logger = LogManager.getLogger(logName);
-    logger.removeAppender(logName);
-    logger.addAppender(asyncAppender);
+    if (logger.getAppender(logName) == null) {
+      synchronized (systemLog) {
+        if (logger.getAppender(logName) == null) {
+          logger.addAppender(systemLog.createAsyncAppender(logName, layout, true, true));
+        }
+      }
+    }
     logger.setAdditivity(false);
   }
 
diff --git a/java/com/google/gerrit/sshd/AbstractGitCommand.java b/java/com/google/gerrit/sshd/AbstractGitCommand.java
index 9efcff2..b3753fd 100644
--- a/java/com/google/gerrit/sshd/AbstractGitCommand.java
+++ b/java/com/google/gerrit/sshd/AbstractGitCommand.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -64,8 +65,8 @@
       startThread(
           new ProjectCommandRunnable() {
             @Override
-            public void executeParseCommand() throws Exception {
-              parseCommandLine();
+            public void executeParseCommand(DynamicOptions pluginOptions) throws Exception {
+              parseCommandLine(pluginOptions);
             }
 
             @Override
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index a5b88b4..0668c1e 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -4,6 +4,7 @@
     name = "sshd",
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
+    runtime_deps = ["//lib:jsch"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
@@ -28,7 +29,7 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:jgit-archive",
-        "//lib:jsch",
+        "//lib:jgit-ssh-apache",
         "//lib:servlet-api",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index 85d9eb2..48a5512 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gerrit.util.cli.EndOfOptionsHandler;
@@ -102,9 +101,9 @@
   @PluginName
   private String pluginName;
 
-  @Inject private Injector injector;
+  @Inject protected Injector injector;
 
-  @Inject private DynamicMap<DynamicOptions.DynamicBean> dynamicBeans = null;
+  @Inject protected DynamicMap<DynamicOptions.DynamicBean> dynamicBeans = null;
 
   /** The task, as scheduled on a worker thread. */
   private final AtomicReference<Future<?>> task;
@@ -212,12 +211,13 @@
    *
    * <p>This method must be explicitly invoked to cause a parse.
    *
+   * @param pluginOptions which helps to define and parse options provided from plugins
    * @throws UnloggedFailure if the command line arguments were invalid.
    * @see Option
    * @see Argument
    */
-  protected void parseCommandLine() throws UnloggedFailure {
-    parseCommandLine(this);
+  protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
+    parseCommandLine(this, pluginOptions);
   }
 
   /**
@@ -227,13 +227,16 @@
    *
    * @param options object whose fields declare Option and Argument annotations to describe the
    *     parameters of the command. Usually {@code this}.
+   * @param pluginOptions which helps to define and parse options provided from plugins
    * @throws UnloggedFailure if the command line arguments were invalid.
    * @see Option
    * @see Argument
    */
-  protected void parseCommandLine(Object options) throws UnloggedFailure {
+  protected void parseCommandLine(Object options, DynamicOptions pluginOptions)
+      throws UnloggedFailure {
     final CmdLineParser clp = newCmdLineParser(options);
-    DynamicOptions pluginOptions = new DynamicOptions(options, injector, dynamicBeans);
+    pluginOptions.setBean(options);
+    pluginOptions.startLifecycleListeners();
     pluginOptions.parseDynamicBeans(clp);
     pluginOptions.setDynamicBeans();
     pluginOptions.onBeanParseStart();
@@ -476,16 +479,19 @@
         context.getSession().setAccessPath(accessPath);
         final Context old = sshScope.set(context);
         try {
-          context.started = TimeUtil.nowMs();
+          context.start();
           thisThread.setName("SSH " + taskName);
 
-          if (thunk instanceof ProjectCommandRunnable) {
-            ((ProjectCommandRunnable) thunk).executeParseCommand();
-            projectName = ((ProjectCommandRunnable) thunk).getProjectName();
-          }
-
           try {
-            thunk.run();
+            if (thunk instanceof ProjectCommandRunnable) {
+              try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
+                ((ProjectCommandRunnable) thunk).executeParseCommand(pluginOptions);
+                projectName = ((ProjectCommandRunnable) thunk).getProjectName();
+                thunk.run();
+              }
+            } else {
+              thunk.run();
+            }
           } catch (NoSuchProjectException e) {
             throw new UnloggedFailure(1, e.getMessage());
           } catch (NoSuchChangeException e) {
@@ -548,7 +554,7 @@
   public interface ProjectCommandRunnable extends CommandRunnable {
     // execute parser command before running, in order to be able to retrieve
     // project name
-    void executeParseCommand() throws Exception;
+    void executeParseCommand(DynamicOptions pluginOptions) throws Exception;
 
     Project.NameKey getProjectName();
   }
diff --git a/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index 38ac26d..bb5494b 100644
--- a/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -39,11 +39,11 @@
 import java.util.concurrent.atomic.AtomicReference;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.ExitCallback;
-import org.apache.sshd.server.SessionAware;
 import org.apache.sshd.server.channel.ChannelSession;
 import org.apache.sshd.server.command.Command;
 import org.apache.sshd.server.command.CommandFactory;
 import org.apache.sshd.server.session.ServerSession;
+import org.apache.sshd.server.session.ServerSessionAware;
 import org.eclipse.jgit.lib.Config;
 
 /** Creates a CommandFactory using commands registered by {@link CommandModule}. */
@@ -102,7 +102,7 @@
     };
   }
 
-  private class Trampoline implements Command, SessionAware {
+  private class Trampoline implements Command, ServerSessionAware {
     private final String commandLine;
     private final String[] argv;
     private InputStream in;
@@ -185,8 +185,8 @@
           cmd.setExitCallback(
               new ExitCallback() {
                 @Override
-                public void onExit(int rc, String exitMessage) {
-                  exit.onExit(translateExit(rc), exitMessage);
+                public void onExit(int rc, String exitMessage, boolean closeImmediately) {
+                  exit.onExit(translateExit(rc), exitMessage, closeImmediately);
                   log(rc, exitMessage);
                 }
 
diff --git a/java/com/google/gerrit/sshd/DispatchCommand.java b/java/com/google/gerrit/sshd/DispatchCommand.java
index 7db65bd..54171a3 100644
--- a/java/com/google/gerrit/sshd/DispatchCommand.java
+++ b/java/com/google/gerrit/sshd/DispatchCommand.java
@@ -20,6 +20,7 @@
 import com.google.common.util.concurrent.Atomics;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.args4j.SubcommandHandler;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -71,8 +72,8 @@
 
   @Override
   public void start(ChannelSession channel, Environment env) throws IOException {
-    try {
-      parseCommandLine();
+    try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
+      parseCommandLine(pluginOptions);
       if (Strings.isNullOrEmpty(commandName)) {
         StringWriter msg = new StringWriter();
         msg.write(usage());
diff --git a/java/com/google/gerrit/sshd/HostKeyProvider.java b/java/com/google/gerrit/sshd/HostKeyProvider.java
index 3578fb9..0e9b46b 100644
--- a/java/com/google/gerrit/sshd/HostKeyProvider.java
+++ b/java/com/google/gerrit/sshd/HostKeyProvider.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -22,11 +23,13 @@
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
+import org.apache.sshd.common.config.keys.KeyUtils;
 import org.apache.sshd.common.keyprovider.FileKeyPairProvider;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
 import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
 
 class HostKeyProvider implements Provider<KeyPairProvider> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private final SitePaths site;
 
   @Inject
@@ -63,7 +66,11 @@
     if (Files.exists(objKey)) {
       if (stdKeys.isEmpty()) {
         SimpleGeneratorHostKeyProvider p = new SimpleGeneratorHostKeyProvider();
+        p.setAlgorithm(KeyUtils.RSA_ALGORITHM);
         p.setPath(objKey.toAbsolutePath());
+        logger.atWarning().log(
+            "Defaulting to RSA algorithm for SSH key exchange."
+                + "This is a weak security setting, consider changing it (see 'sshd.kex' documentation section).");
         return p;
       }
       // Both formats of host key exist, we don't know which format
diff --git a/java/com/google/gerrit/sshd/NoShell.java b/java/com/google/gerrit/sshd/NoShell.java
index 2a29a62..ffac946 100644
--- a/java/com/google/gerrit/sshd/NoShell.java
+++ b/java/com/google/gerrit/sshd/NoShell.java
@@ -32,11 +32,11 @@
 import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.ExitCallback;
-import org.apache.sshd.server.SessionAware;
 import org.apache.sshd.server.channel.ChannelSession;
 import org.apache.sshd.server.command.AsyncCommand;
 import org.apache.sshd.server.command.Command;
 import org.apache.sshd.server.session.ServerSession;
+import org.apache.sshd.server.session.ServerSessionAware;
 import org.apache.sshd.server.shell.ShellFactory;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.util.SystemReader;
@@ -65,7 +65,7 @@
    *
    * @see org.apache.sshd.server.command.AsyncCommand
    */
-  static class SendMessage implements AsyncCommand, SessionAware {
+  static class SendMessage implements AsyncCommand, ServerSessionAware {
     private final Provider<MessageFactory> messageFactory;
     private final SshScope sshScope;
 
@@ -132,7 +132,7 @@
       } finally {
         sshScope.set(old);
       }
-      err.writePacket(new ByteArrayBuffer(Constants.encode(message)));
+      err.writeBuffer(new ByteArrayBuffer(Constants.encode(message)));
 
       in.close();
       out.close();
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index e60ba6d..c94b25c 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -49,19 +50,21 @@
   public void start(ChannelSession channel, Environment env) throws IOException {
     startThread(
         () -> {
-          parseCommandLine();
-          stdout = toPrintWriter(out);
-          stderr = toPrintWriter(err);
-          try (TraceContext traceContext = enableTracing();
-              PerformanceLogContext performanceLogContext =
-                  new PerformanceLogContext(config, performanceLoggers)) {
-            RequestInfo requestInfo =
-                RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
-            requestListeners.runEach(l -> l.onRequest(requestInfo));
-            SshCommand.this.run();
-          } finally {
-            stdout.flush();
-            stderr.flush();
+          try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
+            parseCommandLine(pluginOptions);
+            stdout = toPrintWriter(out);
+            stderr = toPrintWriter(err);
+            try (TraceContext traceContext = enableTracing();
+                PerformanceLogContext performanceLogContext =
+                    new PerformanceLogContext(config, performanceLoggers)) {
+              RequestInfo requestInfo =
+                  RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
+              requestListeners.runEach(l -> l.onRequest(requestInfo));
+              SshCommand.this.run();
+            } finally {
+              stdout.flush();
+              stderr.flush();
+            }
           }
         },
         AccessPath.SSH_COMMAND);
diff --git a/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index fa3529c..553287ec 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -17,7 +17,15 @@
 import static com.google.gerrit.server.ssh.SshAddressesModule.IANA_SSH_PORT;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
-import static org.apache.sshd.common.channel.ChannelOutputStream.WAIT_FOR_SPACE_TIMEOUT;
+import static org.apache.sshd.core.CoreModuleProperties.AUTH_TIMEOUT;
+import static org.apache.sshd.core.CoreModuleProperties.IDLE_TIMEOUT;
+import static org.apache.sshd.core.CoreModuleProperties.MAX_AUTH_REQUESTS;
+import static org.apache.sshd.core.CoreModuleProperties.MAX_CONCURRENT_SESSIONS;
+import static org.apache.sshd.core.CoreModuleProperties.NIO2_READ_TIMEOUT;
+import static org.apache.sshd.core.CoreModuleProperties.REKEY_BYTES_LIMIT;
+import static org.apache.sshd.core.CoreModuleProperties.REKEY_TIME_LIMIT;
+import static org.apache.sshd.core.CoreModuleProperties.SERVER_IDENTIFICATION;
+import static org.apache.sshd.core.CoreModuleProperties.WAIT_FOR_SPACE_TIMEOUT;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
@@ -29,6 +37,7 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.ssh.HostKey;
 import com.google.gerrit.server.ssh.SshAdvertisedAddresses;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gerrit.server.ssh.SshListenAddresses;
@@ -36,32 +45,23 @@
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import com.jcraft.jsch.HostKey;
-import com.jcraft.jsch.JSchException;
 import java.io.File;
 import java.io.IOException;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
 import java.net.UnknownHostException;
-import java.nio.file.FileStore;
-import java.nio.file.FileSystem;
-import java.nio.file.Path;
-import java.nio.file.PathMatcher;
-import java.nio.file.WatchService;
-import java.nio.file.attribute.UserPrincipalLookupService;
-import java.nio.file.spi.FileSystemProvider;
 import java.security.GeneralSecurityException;
 import java.security.InvalidKeyException;
 import java.security.KeyPair;
 import java.security.PublicKey;
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
-import java.util.Set;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.TimeUnit;
@@ -73,6 +73,7 @@
 import org.apache.sshd.common.cipher.Cipher;
 import org.apache.sshd.common.compression.BuiltinCompressions;
 import org.apache.sshd.common.compression.Compression;
+import org.apache.sshd.common.file.nonefs.NoneFileSystemFactory;
 import org.apache.sshd.common.forward.DefaultForwarderFactory;
 import org.apache.sshd.common.future.CloseFuture;
 import org.apache.sshd.common.future.SshFutureListener;
@@ -81,9 +82,8 @@
 import org.apache.sshd.common.io.IoServiceFactory;
 import org.apache.sshd.common.io.IoServiceFactoryFactory;
 import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.common.io.mina.MinaServiceFactoryFactory;
-import org.apache.sshd.common.io.mina.MinaSession;
 import org.apache.sshd.common.io.nio2.Nio2ServiceFactoryFactory;
+import org.apache.sshd.common.kex.BuiltinDHFactories;
 import org.apache.sshd.common.kex.KeyExchangeFactory;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
 import org.apache.sshd.common.mac.Mac;
@@ -96,6 +96,8 @@
 import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
 import org.apache.sshd.common.util.net.SshdSocketAddress;
 import org.apache.sshd.common.util.security.SecurityUtils;
+import org.apache.sshd.mina.MinaServiceFactoryFactory;
+import org.apache.sshd.mina.MinaSession;
 import org.apache.sshd.server.ServerBuilder;
 import org.apache.sshd.server.SshServer;
 import org.apache.sshd.server.auth.UserAuthFactory;
@@ -170,45 +172,38 @@
     this.advertised = advertised;
     keepAlive = cfg.getBoolean("sshd", "tcpkeepalive", true);
 
-    getProperties()
-        .put(
-            SERVER_IDENTIFICATION,
-            "GerritCodeReview_"
-                + Version.getVersion() //
-                + " ("
-                + super.getVersion()
-                + ")");
-
-    getProperties().put(MAX_AUTH_REQUESTS, String.valueOf(cfg.getInt("sshd", "maxAuthTries", 6)));
-
-    getProperties()
-        .put(
-            AUTH_TIMEOUT,
-            String.valueOf(
-                MILLISECONDS.convert(
-                    ConfigUtil.getTimeUnit(cfg, "sshd", null, "loginGraceTime", 120, SECONDS),
-                    SECONDS)));
+    SERVER_IDENTIFICATION.set(
+        this,
+        "GerritCodeReview_"
+            + Version.getVersion() //
+            + " ("
+            + super.getVersion()
+            + ")");
+    MAX_AUTH_REQUESTS.set(this, cfg.getInt("sshd", "maxAuthTries", 6));
+    AUTH_TIMEOUT.set(
+        this,
+        Duration.ofSeconds(
+            MILLISECONDS.convert(
+                ConfigUtil.getTimeUnit(cfg, "sshd", null, "loginGraceTime", 120, SECONDS),
+                SECONDS)));
 
     long idleTimeoutSeconds = ConfigUtil.getTimeUnit(cfg, "sshd", null, "idleTimeout", 0, SECONDS);
-    getProperties().put(IDLE_TIMEOUT, String.valueOf(SECONDS.toMillis(idleTimeoutSeconds)));
-    getProperties().put(NIO2_READ_TIMEOUT, String.valueOf(SECONDS.toMillis(idleTimeoutSeconds)));
+    IDLE_TIMEOUT.set(this, Duration.ofSeconds(SECONDS.toMillis(idleTimeoutSeconds)));
+    NIO2_READ_TIMEOUT.set(this, Duration.ofSeconds(SECONDS.toMillis(idleTimeoutSeconds)));
 
     long rekeyTimeLimit =
         ConfigUtil.getTimeUnit(cfg, "sshd", null, "rekeyTimeLimit", 3600, SECONDS);
-    getProperties().put(REKEY_TIME_LIMIT, String.valueOf(SECONDS.toMillis(rekeyTimeLimit)));
+    REKEY_TIME_LIMIT.set(this, Duration.ofSeconds(SECONDS.toMillis(rekeyTimeLimit)));
 
-    getProperties()
-        .put(
-            REKEY_BYTES_LIMIT,
-            String.valueOf(cfg.getLong("sshd", "rekeyBytesLimit", 1024 * 1024 * 1024 /* 1GB */)));
+    REKEY_BYTES_LIMIT.set(
+        this, cfg.getLong("sshd", "rekeyBytesLimit", 1024 * 1024 * 1024 /* 1GB */));
 
     long waitTimeoutSeconds = ConfigUtil.getTimeUnit(cfg, "sshd", null, "waitTimeout", 30, SECONDS);
-    getProperties()
-        .put(WAIT_FOR_SPACE_TIMEOUT, String.valueOf(SECONDS.toMillis(waitTimeoutSeconds)));
+    WAIT_FOR_SPACE_TIMEOUT.set(this, Duration.ofSeconds(SECONDS.toMillis(waitTimeoutSeconds)));
 
     final int maxConnectionsPerUser = cfg.getInt("sshd", "maxConnectionsPerUser", 64);
     if (0 < maxConnectionsPerUser) {
-      getProperties().put(MAX_CONCURRENT_SESSIONS, String.valueOf(maxConnectionsPerUser));
+      MAX_CONCURRENT_SESSIONS.set(this, maxConnectionsPerUser);
     }
 
     final String kerberosKeytab = cfg.getString("sshd", null, "kerberosKeytab");
@@ -442,12 +437,7 @@
       byte[] keyBin = buf.getCompactData();
 
       for (String addr : advertised) {
-        try {
-          r.add(new HostKey(addr, keyBin));
-        } catch (JSchException e) {
-          logger.atWarning().log(
-              "Cannot format SSHD host key [%s]: %s", pub.getAlgorithm(), e.getMessage());
-        }
+        r.add(new HostKey(addr, keyBin));
       }
     }
 
@@ -490,7 +480,13 @@
   }
 
   private void initKeyExchanges(Config cfg) {
-    List<KeyExchangeFactory> a = ServerBuilder.setUpDefaultKeyExchanges(true);
+    List<KeyExchangeFactory> a =
+        NamedFactory.setUpTransformedFactories(
+            true,
+            cfg.getBoolean("sshd", null, "enableDeprecatedKexAlgorithms", false)
+                ? BuiltinDHFactories.VALUES
+                : BaseBuilder.DEFAULT_KEX_PREFERENCE,
+            ServerBuilder.DH2KEX);
     setKeyExchangeFactories(filter(cfg, "kex", a.toArray(new KeyExchangeFactory[a.size()])));
   }
 
@@ -774,66 +770,6 @@
   }
 
   private void initFileSystemFactory() {
-    setFileSystemFactory(
-        session ->
-            new FileSystem() {
-              @Override
-              public void close() throws IOException {}
-
-              @Override
-              public Iterable<FileStore> getFileStores() {
-                return null;
-              }
-
-              @Override
-              public Path getPath(String arg0, String... arg1) {
-                return null;
-              }
-
-              @Override
-              public PathMatcher getPathMatcher(String arg0) {
-                return null;
-              }
-
-              @Override
-              public Iterable<Path> getRootDirectories() {
-                return null;
-              }
-
-              @Override
-              public String getSeparator() {
-                return null;
-              }
-
-              @Override
-              public UserPrincipalLookupService getUserPrincipalLookupService() {
-                return null;
-              }
-
-              @Override
-              public boolean isOpen() {
-                return false;
-              }
-
-              @Override
-              public boolean isReadOnly() {
-                return false;
-              }
-
-              @Override
-              public WatchService newWatchService() throws IOException {
-                return null;
-              }
-
-              @Override
-              public FileSystemProvider provider() {
-                return null;
-              }
-
-              @Override
-              public Set<String> supportedFileAttributeViews() {
-                return null;
-              }
-            });
+    setFileSystemFactory(NoneFileSystemFactory.INSTANCE);
   }
 }
diff --git a/java/com/google/gerrit/sshd/SshLog.java b/java/com/google/gerrit/sshd/SshLog.java
index 2b3052c..616f7d1 100644
--- a/java/com/google/gerrit/sshd/SshLog.java
+++ b/java/com/google/gerrit/sshd/SshLog.java
@@ -57,6 +57,9 @@
   protected static final String P_STATUS = "status";
   protected static final String P_AGENT = "agent";
   protected static final String P_MESSAGE = "message";
+  protected static final String P_TOTAL_CPU = "totalCpu";
+  protected static final String P_USER_CPU = "userCpu";
+  protected static final String P_MEMORY = "memory";
 
   private final Provider<SshSession> session;
   private final Provider<Context> context;
@@ -171,13 +174,16 @@
 
   void onExecute(DispatchCommand dcmd, int exitValue, SshSession sshSession, String message) {
     final Context ctx = context.get();
-    ctx.finished = TimeUtil.nowMs();
+    ctx.finish();
 
     String cmd = extractWhat(dcmd);
 
     final LoggingEvent event = log(cmd);
-    event.setProperty(P_WAIT, (ctx.started - ctx.created) + "ms");
-    event.setProperty(P_EXEC, (ctx.finished - ctx.started) + "ms");
+    event.setProperty(P_WAIT, ctx.getWait() + "ms");
+    event.setProperty(P_EXEC, ctx.getExec() + "ms");
+    event.setProperty(P_TOTAL_CPU, ctx.getTotalCpu() + "ms");
+    event.setProperty(P_USER_CPU, ctx.getUserCpu() + "ms");
+    event.setProperty(P_MEMORY, String.valueOf(ctx.getAllocatedMemory()));
 
     final String status;
     switch (exitValue) {
@@ -328,7 +334,7 @@
       SshSession session = ctx.getSession();
       sessionId = HexFormat.fromInt(session.getSessionId());
       currentUser = session.getUser();
-      created = ctx.created;
+      created = ctx.getCreated();
     }
     auditService.dispatch(new SshAuditEvent(sessionId, currentUser, cmd, created, params, result));
   }
diff --git a/java/com/google/gerrit/sshd/SshLogJsonLayout.java b/java/com/google/gerrit/sshd/SshLogJsonLayout.java
index 488a4c5..fca0a5a 100644
--- a/java/com/google/gerrit/sshd/SshLogJsonLayout.java
+++ b/java/com/google/gerrit/sshd/SshLogJsonLayout.java
@@ -17,9 +17,12 @@
 import static com.google.gerrit.sshd.SshLog.P_ACCOUNT_ID;
 import static com.google.gerrit.sshd.SshLog.P_AGENT;
 import static com.google.gerrit.sshd.SshLog.P_EXEC;
+import static com.google.gerrit.sshd.SshLog.P_MEMORY;
 import static com.google.gerrit.sshd.SshLog.P_MESSAGE;
 import static com.google.gerrit.sshd.SshLog.P_SESSION;
 import static com.google.gerrit.sshd.SshLog.P_STATUS;
+import static com.google.gerrit.sshd.SshLog.P_TOTAL_CPU;
+import static com.google.gerrit.sshd.SshLog.P_USER_CPU;
 import static com.google.gerrit.sshd.SshLog.P_USER_NAME;
 import static com.google.gerrit.sshd.SshLog.P_WAIT;
 
@@ -44,6 +47,9 @@
     public String message;
     public String waitTime;
     public String execTime;
+    public String totalCpu;
+    public String userCpu;
+    public String memory;
     public String status;
     public String agent;
     public String timeNegotiating;
@@ -67,6 +73,9 @@
       this.message = (String) event.getMessage();
       this.waitTime = getMdcString(event, P_WAIT);
       this.execTime = getMdcString(event, P_EXEC);
+      this.totalCpu = getMdcString(event, P_TOTAL_CPU);
+      this.userCpu = getMdcString(event, P_USER_CPU);
+      this.memory = getMdcString(event, P_MEMORY);
       this.status = getMdcString(event, P_STATUS);
       this.agent = getMdcString(event, P_AGENT);
 
diff --git a/java/com/google/gerrit/sshd/SshLogLayout.java b/java/com/google/gerrit/sshd/SshLogLayout.java
index 1dda068..a1f2c40 100644
--- a/java/com/google/gerrit/sshd/SshLogLayout.java
+++ b/java/com/google/gerrit/sshd/SshLogLayout.java
@@ -17,9 +17,12 @@
 import static com.google.gerrit.sshd.SshLog.P_ACCOUNT_ID;
 import static com.google.gerrit.sshd.SshLog.P_AGENT;
 import static com.google.gerrit.sshd.SshLog.P_EXEC;
+import static com.google.gerrit.sshd.SshLog.P_MEMORY;
 import static com.google.gerrit.sshd.SshLog.P_MESSAGE;
 import static com.google.gerrit.sshd.SshLog.P_SESSION;
 import static com.google.gerrit.sshd.SshLog.P_STATUS;
+import static com.google.gerrit.sshd.SshLog.P_TOTAL_CPU;
+import static com.google.gerrit.sshd.SshLog.P_USER_CPU;
 import static com.google.gerrit.sshd.SshLog.P_USER_NAME;
 import static com.google.gerrit.sshd.SshLog.P_WAIT;
 
@@ -56,11 +59,17 @@
     buf.append(' ');
     buf.append(event.getMessage());
 
-    opt(P_WAIT, buf, event);
-    opt(P_EXEC, buf, event);
-    opt(P_MESSAGE, buf, event);
-    opt(P_STATUS, buf, event);
-    opt(P_AGENT, buf, event);
+    String msg = (String) event.getMessage();
+    if (!(msg.startsWith("LOGIN") || msg.equals("LOGOUT"))) {
+      req(P_WAIT, buf, event);
+      req(P_EXEC, buf, event);
+      req(P_MESSAGE, buf, event);
+      req(P_STATUS, buf, event);
+      req(P_AGENT, buf, event);
+      req(P_TOTAL_CPU, buf, event);
+      req(P_USER_CPU, buf, event);
+      req(P_MEMORY, buf, event);
+    }
 
     buf.append('\n');
     return buf.toString();
@@ -81,14 +90,6 @@
     }
   }
 
-  private void opt(String key, StringBuffer buf, LoggingEvent event) {
-    Object val = event.getMDC(key);
-    if (val != null) {
-      buf.append(' ');
-      buf.append(val);
-    }
-  }
-
   @Override
   public boolean ignoresThrowable() {
     return true;
diff --git a/java/com/google/gerrit/sshd/SshScope.java b/java/com/google/gerrit/sshd/SshScope.java
index 340b910..e9ed750 100644
--- a/java/com/google/gerrit/sshd/SshScope.java
+++ b/java/com/google/gerrit/sshd/SshScope.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.gerrit.metrics.proc.ThreadMXBeanFactory;
+import com.google.gerrit.metrics.proc.ThreadMXBeanInterface;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.RequestCleanup;
@@ -32,21 +34,32 @@
 /** Guice scopes for state during an SSH connection. */
 public class SshScope {
   private static final Key<RequestCleanup> RC_KEY = Key.get(RequestCleanup.class);
+  private static final ThreadMXBeanInterface threadMxBean = ThreadMXBeanFactory.create();
 
   class Context implements RequestContext {
+
     private final RequestCleanup cleanup = new RequestCleanup();
     private final Map<Key<?>, Object> map = new HashMap<>();
     private final SshSession session;
     private final String commandLine;
 
-    final long created;
-    volatile long started;
-    volatile long finished;
+    private final long created;
+    private volatile long started;
+    private volatile long finished;
+    private volatile long startedTotalCpu;
+    private volatile long finishedTotalCpu;
+    private volatile long startedUserCpu;
+    private volatile long finishedUserCpu;
+    private volatile long startedMemory;
+    private volatile long finishedMemory;
 
     private Context(SshSession s, String c, long at) {
       session = s;
       commandLine = c;
       created = started = finished = at;
+      startedTotalCpu = threadMxBean.getCurrentThreadCpuTime();
+      startedUserCpu = threadMxBean.getCurrentThreadUserTime();
+      startedMemory = threadMxBean.getCurrentThreadAllocatedBytes();
       map.put(RC_KEY, cleanup);
     }
 
@@ -54,6 +67,50 @@
       this(s, c, p.created);
       started = p.started;
       finished = p.finished;
+      startedTotalCpu = p.startedTotalCpu;
+      finishedTotalCpu = p.finishedTotalCpu;
+      startedUserCpu = p.startedUserCpu;
+      finishedUserCpu = p.finishedUserCpu;
+      startedMemory = p.startedMemory;
+      finishedMemory = p.finishedMemory;
+    }
+
+    void start() {
+      started = TimeUtil.nowMs();
+      startedTotalCpu = threadMxBean.getCurrentThreadCpuTime();
+      startedUserCpu = threadMxBean.getCurrentThreadUserTime();
+      startedMemory = threadMxBean.getCurrentThreadAllocatedBytes();
+    }
+
+    void finish() {
+      finished = TimeUtil.nowMs();
+      finishedTotalCpu = threadMxBean.getCurrentThreadCpuTime();
+      finishedUserCpu = threadMxBean.getCurrentThreadUserTime();
+      finishedMemory = threadMxBean.getCurrentThreadAllocatedBytes();
+    }
+
+    public long getCreated() {
+      return created;
+    }
+
+    public long getWait() {
+      return started - created;
+    }
+
+    public long getExec() {
+      return finished - started;
+    }
+
+    public long getTotalCpu() {
+      return (finishedTotalCpu - startedTotalCpu) / 1_000_000;
+    }
+
+    public long getUserCpu() {
+      return (finishedUserCpu - startedUserCpu) / 1_000_000;
+    }
+
+    public long getAllocatedMemory() {
+      return finishedMemory - startedMemory;
     }
 
     String getCommandLine() {
diff --git a/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.java b/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.java
new file mode 100644
index 0000000..1cdf923
--- /dev/null
+++ b/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.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.sshd;
+
+import static com.google.gerrit.server.config.SshClientImplementation.APACHE;
+
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.transport.SshSessionFactory;
+import org.eclipse.jgit.transport.sshd.DefaultProxyDataFactory;
+import org.eclipse.jgit.transport.sshd.JGitKeyCache;
+import org.eclipse.jgit.transport.sshd.SshdSessionFactory;
+import org.eclipse.jgit.util.FS;
+
+public class SshSessionFactoryInitializer {
+  public static void init(Config config) {
+    if (APACHE == config.getEnum("ssh", null, "clientImplementation", APACHE)) {
+      SshdSessionFactory factory =
+          new SshdSessionFactory(new JGitKeyCache(), new DefaultProxyDataFactory());
+      factory.setHomeDirectory(FS.DETECTED.userHome());
+      SshSessionFactory.setInstance(factory);
+    }
+  }
+
+  private SshSessionFactoryInitializer() {}
+}
diff --git a/java/com/google/gerrit/sshd/SuExec.java b/java/com/google/gerrit/sshd/SuExec.java
index ea163d5..3c6e8c2 100644
--- a/java/com/google/gerrit/sshd/SuExec.java
+++ b/java/com/google/gerrit/sshd/SuExec.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.config.AuthConfig;
@@ -92,9 +93,9 @@
 
   @Override
   public void start(ChannelSession channel, Environment env) throws IOException {
-    try {
+    try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
       checkCanRunAs();
-      parseCommandLine();
+      parseCommandLine(pluginOptions);
 
       final Context ctx = callingContext.subContext(newSession(), join(args));
       final Context old = sshScope.set(ctx);
diff --git a/java/com/google/gerrit/sshd/commands/ConvertRefStorage.java b/java/com/google/gerrit/sshd/commands/ConvertRefStorage.java
new file mode 100644
index 0000000..98626de
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/ConvertRefStorage.java
@@ -0,0 +1,92 @@
+// 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 static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.server.git.DelegateRepository;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
+import org.kohsuke.args4j.spi.ExplicitBooleanOptionHandler;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@CommandMetaData(
+    name = "convert-ref-storage",
+    description = "Convert ref storage to reftable (experimental)",
+    runsAt = MASTER_OR_SLAVE)
+public class ConvertRefStorage extends SshCommand {
+  @Inject private GitRepositoryManager repoManager;
+
+  private enum StorageFormatOption {
+    reftable,
+    refdir,
+  }
+
+  @Option(name = "--format", usage = "storage format to convert to (reftable or refdir)")
+  private StorageFormatOption storageFormat = StorageFormatOption.reftable;
+
+  @Option(
+      name = "--backup",
+      aliases = {"-b"},
+      usage = "create backup of old ref storage format",
+      handler = ExplicitBooleanOptionHandler.class)
+  private boolean backup = true;
+
+  @Option(
+      name = "--reflogs",
+      aliases = {"-r"},
+      usage = "write reflogs to reftable",
+      handler = ExplicitBooleanOptionHandler.class)
+  private boolean writeLogs = true;
+
+  @Option(
+      name = "--project",
+      aliases = {"-p"},
+      metaVar = "PROJECT",
+      required = true,
+      usage = "project for which the storage format should be changed")
+  private ProjectState projectState;
+
+  @Override
+  public void run() throws Exception {
+    enableGracefulStop();
+    Project.NameKey projectName = projectState.getNameKey();
+    try (Repository repo = repoManager.openRepository(projectName)) {
+      if (repo instanceof DelegateRepository) {
+        ((DelegateRepository) repo).convertRefStorage(storageFormat.name(), writeLogs, backup);
+      } else {
+        checkState(
+            repo instanceof FileRepository, "Repository is not an instance of FileRepository!");
+        ((FileRepository) repo).convertRefStorage(storageFormat.name(), writeLogs, backup);
+      }
+    } catch (RepositoryNotFoundException e) {
+      throw die("'" + projectName + "': not a git archive", e);
+    } catch (IOException e) {
+      throw die("Error converting: '" + projectName + "': " + e.getMessage(), e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
index f2ab4e8..e2d554d 100644
--- a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
@@ -143,7 +143,7 @@
       name = "--branch",
       aliases = {"-b"},
       metaVar = "BRANCH",
-      usage = "initial branch name\n(default: master)")
+      usage = "initial branch name\n(default: gerrit.defaultProject)")
   private List<String> branch;
 
   @Option(name = "--empty-commit", usage = "to create initial empty commit")
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index cfd17f4..8ee6a0d 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -47,6 +47,7 @@
     command(gerrit, AproposCommand.class);
     command(gerrit, BanCommitCommand.class);
     command(gerrit, CloseConnection.class);
+    command(gerrit, ConvertRefStorage.class);
     command(gerrit, FlushCaches.class);
     command(gerrit, ListProjectsCommand.class);
     command(gerrit, ListMembersCommand.class);
diff --git a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
index 7bf42eb..35699af 100644
--- a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
@@ -19,10 +19,10 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.ioutil.ColumnFormatter;
 import com.google.gerrit.server.restapi.group.ListGroups;
 import com.google.gerrit.sshd.CommandMetaData;
diff --git a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
index 37f4245..fd18656 100644
--- a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -19,11 +19,12 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.ioutil.ColumnFormatter;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.ListMembers;
@@ -50,8 +51,8 @@
   }
 
   @Override
-  protected void parseCommandLine() throws UnloggedFailure {
-    parseCommandLine(impl);
+  protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
+    parseCommandLine(impl, pluginOptions);
   }
 
   private static class ListMembersCommandImpl extends ListMembers {
diff --git a/java/com/google/gerrit/sshd/commands/Query.java b/java/com/google/gerrit/sshd/commands/Query.java
index 772eabe..da19153 100644
--- a/java/com/google/gerrit/sshd/commands/Query.java
+++ b/java/com/google/gerrit/sshd/commands/Query.java
@@ -116,9 +116,9 @@
   }
 
   @Override
-  protected void parseCommandLine() throws UnloggedFailure {
+  protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
     processor.setOutput(out, OutputFormat.TEXT);
-    super.parseCommandLine();
+    super.parseCommandLine(pluginOptions);
     if (processor.getIncludeFiles()
         && !(processor.getIncludePatchSets() || processor.getIncludeCurrentPatchSet())) {
       throw die("--files option needs --patch-sets or --current-patch-set");
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index b58cc45..4c84bd3 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -320,7 +321,7 @@
   }
 
   @Override
-  protected void parseCommandLine() throws UnloggedFailure {
+  protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
     optionMap = new LinkedHashMap<>();
     customLabels = new HashMap<>();
 
@@ -341,7 +342,7 @@
       optionMap.put(newApproveOption(type, usage.toString()), new LabelSetter(type));
     }
 
-    super.parseCommandLine();
+    super.parseCommandLine(pluginOptions);
   }
 
   private static String asOptionName(LabelType type) {
diff --git a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
index db8e42a..f788f14 100644
--- a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -28,7 +29,6 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.GroupResource;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.restapi.group.AddMembers;
 import com.google.gerrit.server.restapi.group.AddSubgroups;
 import com.google.gerrit.server.restapi.group.DeleteMembers;
diff --git a/java/com/google/gerrit/sshd/commands/SetTopicCommand.java b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
index 70700f1..35cb3ba 100644
--- a/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
@@ -16,12 +16,11 @@
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.TopicInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetTopicOp;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.restapi.change.SetTopicOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.sshd.ChangeArgumentParser;
@@ -74,18 +73,17 @@
 
   @Override
   public void run() throws Exception {
-    TopicInput input = new TopicInput();
     if (topic != null) {
-      input.topic = topic.trim();
+      topic = topic.trim();
     }
 
-    if (input.topic != null && input.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
+    if (topic != null && topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
       throw new BadRequestException(
           String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
     }
 
     for (ChangeResource r : changes.values()) {
-      SetTopicOp op = topicOpFactory.create(input);
+      SetTopicOp op = topicOpFactory.create(topic);
       try (BatchUpdate u =
           updateFactory.create(r.getChange().getProject(), user, TimeUtil.nowTs())) {
         u.addOp(r.getId(), op);
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 219ceaa..02956f7 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -49,7 +49,7 @@
 import java.util.Map;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.common.io.mina.MinaSession;
+import org.apache.sshd.mina.MinaSession;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.channel.ChannelSession;
 import org.kohsuke.args4j.Option;
diff --git a/java/com/google/gerrit/sshd/commands/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
index d271364..7eeb770 100644
--- a/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -39,10 +39,10 @@
 import java.util.Optional;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.common.io.mina.MinaAcceptor;
-import org.apache.sshd.common.io.mina.MinaSession;
 import org.apache.sshd.common.io.nio2.Nio2Acceptor;
 import org.apache.sshd.common.session.helpers.AbstractSession;
+import org.apache.sshd.mina.MinaAcceptor;
+import org.apache.sshd.mina.MinaSession;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.channel.ChannelSession;
 import org.kohsuke.args4j.Option;
diff --git a/java/com/google/gerrit/sshd/commands/StreamEvents.java b/java/com/google/gerrit/sshd/commands/StreamEvents.java
index 45540a0..c47d24c 100644
--- a/java/com/google/gerrit/sshd/commands/StreamEvents.java
+++ b/java/com/google/gerrit/sshd/commands/StreamEvents.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.EventGson;
@@ -107,59 +108,62 @@
 
   @Override
   public void start(ChannelSession channel, Environment env) throws IOException {
-    try {
-      parseCommandLine();
-    } catch (UnloggedFailure e) {
-      String msg = e.getMessage();
-      if (!msg.endsWith("\n")) {
-        msg += "\n";
+    try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
+      try {
+        parseCommandLine(pluginOptions);
+      } catch (UnloggedFailure e) {
+        String msg = e.getMessage();
+        if (!msg.endsWith("\n")) {
+          msg += "\n";
+        }
+        err.write(msg.getBytes(UTF_8));
+        err.flush();
+        onExit(1);
+        return;
       }
-      err.write(msg.getBytes(UTF_8));
-      err.flush();
-      onExit(1);
-      return;
-    }
 
-    PrintWriter stdout = toPrintWriter(out);
-    CancelableRunnable writer =
-        new CancelableRunnable() {
-          @Override
-          public void run() {
-            writeEvents(this, stdout);
-          }
-
-          @Override
-          public void cancel() {
-            onExit(0);
-          }
-
-          @Override
-          public String toString() {
-            StringBuilder b = new StringBuilder();
-            b.append("Stream Events");
-            if (currentUser.getUserName().isPresent()) {
-              b.append(" (").append(currentUser.getUserName().get()).append(")");
+      PrintWriter stdout = toPrintWriter(out);
+      CancelableRunnable writer =
+          new CancelableRunnable() {
+            @Override
+            public void run() {
+              writeEvents(this, stdout);
             }
-            return b.toString();
-          }
-        };
 
-    eventListenerRegistration =
-        eventListeners.add(
-            "gerrit",
-            new UserScopedEventListener() {
-              @Override
-              public void onEvent(Event event) {
-                if (subscribedToEvents.isEmpty() || subscribedToEvents.contains(event.getType())) {
-                  offer(writer, event);
+            @Override
+            public void cancel() {
+              onExit(0);
+            }
+
+            @Override
+            public String toString() {
+              StringBuilder b = new StringBuilder();
+              b.append("Stream Events");
+              if (currentUser.getUserName().isPresent()) {
+                b.append(" (").append(currentUser.getUserName().get()).append(")");
+              }
+              return b.toString();
+            }
+          };
+
+      eventListenerRegistration =
+          eventListeners.add(
+              "gerrit",
+              new UserScopedEventListener() {
+                @Override
+                public void onEvent(Event event) {
+                  if (subscribedToEvents.isEmpty()
+                      || subscribedToEvents.contains(event.getType())) {
+                    offer(writer, event);
+                  }
                 }
-              }
 
-              @Override
-              public CurrentUser getUser() {
-                return currentUser;
-              }
-            });
+                @Override
+                public CurrentUser getUser() {
+                  return currentUser;
+                }
+              });
+    }
   }
 
   private void removeEventListenerRegistration() {
diff --git a/java/com/google/gerrit/sshd/commands/UploadArchive.java b/java/com/google/gerrit/sshd/commands/UploadArchive.java
index 67dc5a5..0eda433 100644
--- a/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ b/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -73,6 +73,14 @@
     @Option(name = "--prefix", usage = "Prepend <prefix>/ to each filename in the archive.")
     private String prefix;
 
+    @Option(
+        name = "--compression-level",
+        usage =
+            "Controls compression for different formats. The value is in [0-9] with 0 for fast levels"
+                + " with medium compressions, and 9 for the highest compression. Note that higher"
+                + " compressions require more memory.")
+    private int compressionLevel = -1;
+
     @Option(name = "-0", usage = "Store the files instead of deflating them.")
     private boolean level0;
 
@@ -223,6 +231,9 @@
   }
 
   private Map<String, Object> getFormatOptions(ArchiveFormatInternal f) {
+    if (options.compressionLevel != -1) {
+      return ImmutableMap.of("compression-level", options.compressionLevel);
+    }
     if (f == ArchiveFormatInternal.ZIP) {
       int value =
           Arrays.asList(
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index 16c15ad..93c996e8 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -15,11 +15,13 @@
     deps = [
         "//java/com/google/gerrit/acceptance/config",
         "//java/com/google/gerrit/acceptance/testsuite/project",
+        "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/gpg",
+        "//java/com/google/gerrit/httpd/auth/restapi",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/lifecycle",
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 1779a18..1c322b2 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -22,10 +22,12 @@
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperationsImpl;
+import com.google.gerrit.auth.AuthModule;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
 import com.google.gerrit.gpg.GpgModule;
+import com.google.gerrit.httpd.auth.restapi.OAuthRestModule;
 import com.google.gerrit.index.IndexType;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
@@ -41,12 +43,14 @@
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
+import com.google.gerrit.server.change.FileInfoJsonModule;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.DefaultUrlFormatter;
@@ -170,6 +174,9 @@
     bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
     bind(MetricMaker.class).to(DisabledMetricMaker.class);
     install(cfgInjector.getInstance(GerritGlobalModule.class));
+
+    AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
+    install(new AuthModule(authConfig));
     install(new GerritApiModule());
     factory(PluginUser.Factory.class);
     install(new PluginApiModule());
@@ -239,7 +246,9 @@
     bind(ServerInformationImpl.class);
     bind(ServerInformation.class).to(ServerInformationImpl.class);
     install(new RestApiModule());
+    install(new OAuthRestModule());
     install(new DefaultProjectNameLockManager.Module());
+    install(new FileInfoJsonModule(cfg));
 
     bind(ProjectOperations.class).to(ProjectOperationsImpl.class);
   }
diff --git a/java/com/google/gerrit/testing/TestLoggingActivator.java b/java/com/google/gerrit/testing/TestLoggingActivator.java
index a766429..6b5d8fd 100644
--- a/java/com/google/gerrit/testing/TestLoggingActivator.java
+++ b/java/com/google/gerrit/testing/TestLoggingActivator.java
@@ -31,6 +31,7 @@
 
           // Silence non-critical messages from MINA SSHD.
           .put("org.apache.mina", Level.WARN)
+          .put("org.apache.sshd.client", Level.WARN)
           .put("org.apache.sshd.common", Level.WARN)
           .put("org.apache.sshd.server", Level.WARN)
           .put("org.apache.sshd.common.keyprovider.FileKeyPairProvider", Level.INFO)
@@ -61,6 +62,8 @@
           // Silence non-critical messages from JGit.
           .put("org.eclipse.jgit.transport.PacketLineIn", Level.WARN)
           .put("org.eclipse.jgit.transport.PacketLineOut", Level.WARN)
+          .put("org.eclipse.jgit.internal.transport.sshd", Level.WARN)
+          .put("org.eclipse.jgit.util.FileUtils", Level.WARN)
           .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)
diff --git a/java/com/google/gerrit/util/cli/CmdLineParser.java b/java/com/google/gerrit/util/cli/CmdLineParser.java
index e002eeb..7c42797 100644
--- a/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -563,20 +563,22 @@
      *
      * @param name name
      * @return the {@code OptionHandler} or {@code null}
-     *     <p>Note: this is cut & pasted from the parent class in arg4j, it was private and it
-     *     needed to be exposed.
+     *     <p>Note: this was originally cut & pasted from the parent class in arg4j, it was private
+     *     and it needed to be exposed.
      */
     @SuppressWarnings("rawtypes")
     public OptionHandler findOptionByName(String name) {
       for (OptionHandler h : optionsList) {
-        NamedOptionDef option = (NamedOptionDef) h.option;
-        if (name.equals(option.name())) {
-          return h;
-        }
-        for (String alias : option.aliases()) {
-          if (name.equals(alias)) {
+        if (h.option instanceof NamedOptionDef) {
+          NamedOptionDef option = (NamedOptionDef) h.option;
+          if (name.equals(option.name())) {
             return h;
           }
+          for (String alias : option.aliases()) {
+            if (name.equals(alias)) {
+              return h;
+            }
+          }
         }
       }
       return null;
diff --git a/java/gerrit/BUILD b/java/gerrit/BUILD
index 7dbf751..db831b7 100644
--- a/java/gerrit/BUILD
+++ b/java/gerrit/BUILD
@@ -5,7 +5,6 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
diff --git a/java/gerrit/PRED_commit_delta_4.java b/java/gerrit/PRED_commit_delta_4.java
index 6e971fc..502b15b 100644
--- a/java/gerrit/PRED_commit_delta_4.java
+++ b/java/gerrit/PRED_commit_delta_4.java
@@ -14,11 +14,10 @@
 
 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.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
@@ -28,8 +27,15 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 import com.googlecode.prolog_cafe.lang.VariableTerm;
+import java.io.IOException;
 import java.util.Iterator;
+import java.util.List;
 import java.util.regex.Pattern;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 /**
  * Given a regular expression, checks it against the file list in the most recent patchset of a
@@ -76,10 +82,22 @@
     engine.r3 = arg3;
     engine.r4 = arg4;
 
-    PatchList pl = StoredValues.PATCH_LIST.get(engine);
-    Iterator<PatchListEntry> iter = pl.getPatches().iterator();
+    Repository repository = StoredValues.REPOSITORY.get(engine);
 
-    engine.r5 = new JavaObjectTerm(iter);
+    try (DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+      diffFormatter.setRepository(repository);
+      // Do not detect renames; that would require reading file contents, which is slow for large
+      // files.
+      RevCommit commit = StoredValues.COMMIT.get(engine);
+      List<DiffEntry> diffEntries =
+          diffFormatter.scan(
+              // In case of a merge commit, i.e. >1 parents, we use parent #0 by convention. So
+              // parent #0 is always the right choice, if it exists.
+              commit.getParentCount() > 0 ? commit.getParent(0) : null, commit);
+      engine.r5 = new JavaObjectTerm(diffEntries.iterator());
+    } catch (IOException e) {
+      throw new JavaException(e);
+    }
 
     return engine.jtry5(commit_delta_check, commit_delta_next);
   }
@@ -95,23 +113,22 @@
 
       Pattern regex = (Pattern) ((JavaObjectTerm) a1).object();
       @SuppressWarnings("unchecked")
-      Iterator<PatchListEntry> iter = (Iterator<PatchListEntry>) ((JavaObjectTerm) a5).object();
+      Iterator<DiffEntry> iter = (Iterator<DiffEntry>) ((JavaObjectTerm) a5).object();
       while (iter.hasNext()) {
-        PatchListEntry patch = iter.next();
-        String newName = patch.getNewName();
-        String oldName = patch.getOldName();
-        Patch.ChangeType changeType = patch.getChangeType();
+        DiffEntry diffEntry = iter.next();
+        String newName = diffEntry.getNewPath();
+        String oldName = diffEntry.getOldPath();
+        DiffEntry.ChangeType changeType = diffEntry.getChangeType();
 
-        if (Patch.isMagic(newName)) {
-          continue;
-        }
-
-        if (regex.matcher(newName).find() || (oldName != null && regex.matcher(oldName).find())) {
+        if ((!isNull(newName) && regex.matcher(newName).find())
+            || (!isNull(oldName) && regex.matcher(oldName).find())) {
           SymbolTerm changeSym = getTypeSymbol(changeType);
-          SymbolTerm newSym = SymbolTerm.create(newName);
-          SymbolTerm oldSym = Prolog.Nil;
-          if (oldName != null) {
-            oldSym = SymbolTerm.create(oldName);
+          SymbolTerm newSym = isNull(newName) ? Prolog.Nil : SymbolTerm.create(newName);
+          SymbolTerm oldSym = isNull(oldName) ? Prolog.Nil : SymbolTerm.create(oldName);
+          // For compatibility with legacy semantics:
+          if (changeSym.equals(delete)) {
+            newSym = oldSym;
+            oldSym = Prolog.Nil;
           }
 
           if (!a2.unify(changeSym, engine.trail)) {
@@ -130,6 +147,10 @@
     }
   }
 
+  private static boolean isNull(String path) {
+    return path.equals("/dev/null");
+  }
+
   private static final class PRED_commit_delta_next extends Operation {
     @Override
     public Operation exec(Prolog engine) {
@@ -152,20 +173,18 @@
     }
   }
 
-  private static SymbolTerm getTypeSymbol(Patch.ChangeType type) {
+  private static SymbolTerm getTypeSymbol(DiffEntry.ChangeType type) {
     switch (type) {
-      case ADDED:
+      case ADD:
         return add;
-      case MODIFIED:
+      case MODIFY:
         return modify;
-      case DELETED:
+      case DELETE:
         return delete;
-      case RENAMED:
+      case RENAME:
         return rename;
-      case COPIED:
+      case COPY:
         return copy;
-      case REWRITE:
-        break;
     }
     throw new IllegalArgumentException("ChangeType not recognized");
   }
diff --git a/java/com/google/gerrit/acceptance/WaitUtilTest.java b/javatests/com/google/gerrit/acceptance/WaitUtilTest.java
similarity index 100%
rename from java/com/google/gerrit/acceptance/WaitUtilTest.java
rename to javatests/com/google/gerrit/acceptance/WaitUtilTest.java
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 1b55652..7495e63 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -75,6 +75,7 @@
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.acceptance.testsuite.request.SshSessionFactory;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
@@ -150,9 +151,9 @@
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.jcraft.jsch.KeyPair;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.security.KeyPair;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -2001,7 +2002,7 @@
 
       // Add a new key
       sender.clear();
-      String newKey = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email());
+      String newKey = TestSshKeys.publicKey(SshSessionFactory.genSshKey(), admin.email());
       gApi.accounts().self().addSshKey(newKey);
       info = gApi.accounts().self().listSshKeys();
       assertThat(info).hasSize(2);
@@ -2023,7 +2024,7 @@
 
       // Add another new key
       sender.clear();
-      String newKey2 = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email());
+      String newKey2 = TestSshKeys.publicKey(SshSessionFactory.genSshKey(), admin.email());
       gApi.accounts().self().addSshKey(newKey2);
       info = gApi.accounts().self().listSshKeys();
       assertThat(info).hasSize(3);
@@ -2074,7 +2075,7 @@
 
       // Add a new key
       sender.clear();
-      String newKey = TestSshKeys.publicKey(TestSshKeys.genSshKey(), user.email());
+      String newKey = TestSshKeys.publicKey(SshSessionFactory.genSshKey(), user.email());
       gApi.accounts().id(user.username()).addSshKey(newKey);
       info = gApi.accounts().id(user.username()).listSshKeys();
       assertThat(info).hasSize(2);
@@ -2103,7 +2104,7 @@
   @Test
   @UseSsh
   public void userCannotAddSshKeyToOtherAccount() throws Exception {
-    String newKey = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email());
+    String newKey = TestSshKeys.publicKey(SshSessionFactory.genSshKey(), admin.email());
     requestScopeOperations.setApiUser(user.id());
     assertThrows(AuthException.class, () -> gApi.accounts().id(admin.username()).addSshKey(newKey));
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index 24b7998..9b77b01 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.ContributorAgreement;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -49,7 +50,6 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 7c504b8..a4f91d7 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -47,6 +47,7 @@
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
@@ -91,8 +92,10 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
@@ -141,8 +144,10 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -154,6 +159,7 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.testing.TestChangeETagComputation;
+import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
@@ -176,6 +182,7 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -235,7 +242,7 @@
     assertThat(c.status).isEqualTo(ChangeStatus.NEW);
     assertThat(c.subject).isEqualTo("test commit");
     assertThat(c.submitType).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
-    assertThat(c.mergeable).isTrue();
+    assertThat(c.mergeable).isNull();
     assertThat(c.changeId).isEqualTo(r.getChangeId());
     assertThat(c.created).isEqualTo(c.updated);
     assertThat(c._number).isEqualTo(r.getChange().getId().get());
@@ -611,7 +618,7 @@
     ReviewInput in =
         ReviewInput.approve()
             .reviewer(user.email())
-            .label("Code-Review", 1)
+            .label(LabelId.CODE_REVIEW, 1)
             .setWorkInProgress(true);
     gApi.changes().id(r.getChangeId()).current().review(in);
 
@@ -619,7 +626,8 @@
     assertThat(info.workInProgress).isTrue();
     assertThat(info.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
         .containsExactly(admin.id().get(), user.id().get());
-    assertThat(info.labels.get("Code-Review").recommended._accountId).isEqualTo(admin.id().get());
+    assertThat(info.labels.get(LabelId.CODE_REVIEW).recommended._accountId)
+        .isEqualTo(admin.id().get());
   }
 
   @Test
@@ -694,6 +702,8 @@
   @Test
   public void reviewWithReadyByNonOwnerReturnsError() throws Exception {
     PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+
     ReviewInput in = ReviewInput.noScore().setReady(true);
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
@@ -774,7 +784,7 @@
     assertThat(description).isEqualTo("Rebase");
 
     // ...and the approval was copied
-    LabelInfo cr = c2.labels.get("Code-Review");
+    LabelInfo cr = c2.labels.get(LabelId.CODE_REVIEW);
     assertThat(cr).isNotNull();
     assertThat(cr.all).hasSize(1);
     assertThat(cr.all.get(0).value).isEqualTo(1);
@@ -1338,9 +1348,135 @@
             "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
     PushOneCommit.Result r2 = push.to("refs/for/master");
     r2.assertOkStatus();
-    assertThrows(
-        ResourceConflictException.class,
-        () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase());
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "The change could not be rebased due to a conflict during merge.\n\n"
+                    + "merge conflict(s):\n%s",
+                PushOneCommit.FILE_NAME));
+  }
+
+  @Test
+  public void rebaseDoesNotAddWorkInProgress() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // create an unrelated change so that we can rebase
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result unrelated = createChange();
+    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+    gApi.changes().id(r.getChangeId()).rebase();
+
+    // change is still ready for review after rebase
+    assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isNull();
+  }
+
+  @Test
+  public void rebaseDoesNotRemoveWorkInProgress() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+
+    // create an unrelated change so that we can rebase
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result unrelated = createChange();
+    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+    gApi.changes().id(r.getChangeId()).rebase();
+
+    // change is still work in progress after rebase
+    assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isTrue();
+  }
+
+  @Test
+  public void rebaseConflict_conflictsAllowed() throws Exception {
+    String patchSetSubject = "patch set change";
+    String patchSetContent = "patch set content";
+    String baseSubject = "base change";
+    String baseContent = "base content";
+
+    PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
+    gApi.changes()
+        .id(r1.getChangeId())
+        .revision(r1.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+    testRepo.reset("HEAD~1");
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(), testRepo, patchSetSubject, PushOneCommit.FILE_NAME, patchSetContent);
+    PushOneCommit.Result r2 = push.to("refs/for/master");
+    r2.assertOkStatus();
+
+    String changeId = r2.getChangeId();
+    RevCommit patchSet = r2.getCommit();
+    RevCommit base = r1.getCommit();
+
+    TestWorkInProgressStateChangedListener wipStateChangedListener =
+        new TestWorkInProgressStateChangedListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+      RebaseInput rebaseInput = new RebaseInput();
+      rebaseInput.allowConflicts = true;
+      ChangeInfo changeInfo =
+          gApi.changes().id(changeId).revision(patchSet.name()).rebaseAsInfo(rebaseInput);
+      assertThat(changeInfo.containsGitConflicts).isTrue();
+      assertThat(changeInfo.workInProgress).isTrue();
+    }
+    assertThat(wipStateChangedListener.invoked).isTrue();
+    assertThat(wipStateChangedListener.wip).isTrue();
+
+    // To get the revisions, we must retrieve the change with more change options.
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(base.name());
+
+    // Verify that the file content in the created patch set is correct.
+    // We expect that it has conflict markers to indicate the conflict.
+    BinaryResult bin =
+        gApi.changes().id(changeId).current().file(PushOneCommit.FILE_NAME).content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String fileContent = new String(os.toByteArray(), UTF_8);
+    String patchSetSha1 = abbreviateName(patchSet, 6);
+    String baseSha1 = abbreviateName(base, 6);
+    assertThat(fileContent)
+        .isEqualTo(
+            "<<<<<<< PATCH SET ("
+                + patchSetSha1
+                + " "
+                + patchSetSubject
+                + ")\n"
+                + patchSetContent
+                + "\n"
+                + "=======\n"
+                + baseContent
+                + "\n"
+                + ">>>>>>> BASE      ("
+                + baseSha1
+                + " "
+                + baseSubject
+                + ")\n");
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+    assertThat(messages).hasSize(2);
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            "Patch Set 2: Patch Set 1 was rebased\n\n"
+                + "The following files contain Git conflicts:\n"
+                + "* "
+                + PushOneCommit.FILE_NAME
+                + "\n");
   }
 
   @Test
@@ -1504,19 +1640,19 @@
     assertThat(commit.author.email).isEqualTo(user.email());
     assertThat(commit.committer.email).isEqualTo(user.email());
 
-    // check that the author/committer was added as reviewer
-    Collection<AccountInfo> reviewers = change.reviewers.get(REVIEWER);
+    // check that the author/committer was added as cc
+    Collection<AccountInfo> reviewers = change.reviewers.get(CC);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
     assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.id().get());
-    assertThat(change.reviewers.get(CC)).isNull();
+    assertThat(change.reviewers.get(REVIEWER)).isNull();
 
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.from().name()).isEqualTo("Administrator (Code Review)");
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
-    assertThat(m.body()).contains("I'd like you to do a code review");
+    assertThat(m.body()).contains("has uploaded this change for review");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
     assertMailReplyTo(m, admin.email());
   }
@@ -2193,7 +2329,7 @@
         gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).votes();
 
     assertThat(m).hasSize(1);
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 2));
+    assertThat(m).containsExactly(LabelId.CODE_REVIEW, Short.valueOf((short) 2));
 
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.dislike());
@@ -2201,7 +2337,7 @@
     m = gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).votes();
 
     assertThat(m).hasSize(1);
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) -1));
+    assertThat(m).containsExactly(LabelId.CODE_REVIEW, Short.valueOf((short) -1));
   }
 
   @Test
@@ -2215,18 +2351,18 @@
     // check finding by address works
     Map<String, Short> m = gApi.changes().id(r.getChangeId()).reviewer(admin.email()).votes();
     assertThat(m).hasSize(1);
-    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 2));
+    assertThat(m).containsEntry(LabelId.CODE_REVIEW, Short.valueOf((short) 2));
 
     // check finding by id works
     m = gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).votes();
     assertThat(m).hasSize(1);
-    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 2));
+    assertThat(m).containsEntry(LabelId.CODE_REVIEW, Short.valueOf((short) 2));
   }
 
   @Test
   public void removeReviewerNoVotes() throws Exception {
     LabelType verified =
-        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().upsertLabelType(verified);
       u.save();
@@ -2258,6 +2394,10 @@
     assertThat(message.body()).contains("Removed reviewer " + user.fullName() + ".");
     assertThat(message.body()).doesNotContain("with the following votes");
 
+    // Make sure the change message for removing a reviewer is correct.
+    assertThat(Iterables.getLast(gApi.changes().id(changeId).messages()).message)
+        .contains("Removed reviewer " + user.fullName());
+
     // Make sure the reviewer can still be added again.
     gApi.changes().id(changeId).addReviewer(user.id().toString());
     c = gApi.changes().id(changeId).get();
@@ -2273,6 +2413,31 @@
   }
 
   @Test
+  public void removeCC() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    // Add a cc
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = CC;
+    addReviewerInput.reviewer = user.id().toString();
+    gApi.changes().id(changeId).addReviewer(addReviewerInput);
+
+    // Remove a cc
+    sender.clear();
+    gApi.changes().id(changeId).reviewer(user.id().toString()).remove();
+    assertThat(gApi.changes().id(changeId).get().reviewers).isEmpty();
+
+    // Make sure the email for removing a cc is correct.
+    assertThat(sender.getMessages()).hasSize(1);
+    Message message = sender.getMessages().get(0);
+    assertThat(message.body()).contains("Removed cc " + user.fullName() + ".");
+
+    // Make sure the change message for removing a reviewer is correct.
+    assertThat(Iterables.getLast(gApi.changes().id(changeId).messages()).message)
+        .contains("Removed cc " + user.fullName());
+  }
+
+  @Test
   public void removeReviewer() throws Exception {
     testRemoveReviewer(true);
   }
@@ -2403,7 +2568,10 @@
 
     requestScopeOperations.setApiUser(admin.id());
     sender.clear();
-    gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).deleteVote("Code-Review");
+    gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(user.id().toString())
+        .deleteVote(LabelId.CODE_REVIEW);
 
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
@@ -2417,7 +2585,7 @@
         gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).votes();
 
     // Dummy 0 approval on the change to block vote copying to this patch set.
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
+    assertThat(m).containsExactly(LabelId.CODE_REVIEW, Short.valueOf((short) 0));
 
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
@@ -2439,7 +2607,7 @@
     requestScopeOperations.setApiUser(admin.id());
     sender.clear();
     DeleteVoteInput in = new DeleteVoteInput();
-    in.label = "Code-Review";
+    in.label = LabelId.CODE_REVIEW;
     in.notify = NotifyHandling.NONE;
     gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).deleteVote(in);
     assertThat(sender.getMessages()).isEmpty();
@@ -2451,7 +2619,7 @@
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
 
     DeleteVoteInput in = new DeleteVoteInput();
-    in.label = "Code-Review";
+    in.label = LabelId.CODE_REVIEW;
     in.notify = NotifyHandling.NONE;
 
     // notify unrelated account as TO
@@ -2505,14 +2673,14 @@
                 gApi.changes()
                     .id(r.getChangeId())
                     .reviewer(admin.id().toString())
-                    .deleteVote("Code-Review"));
+                    .deleteVote(LabelId.CODE_REVIEW));
     assertThat(thrown).hasMessageThat().contains("delete vote not permitted");
   }
 
   @Test
   public void nonVotingReviewerStaysAfterSubmit() throws Exception {
     LabelType verified =
-        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     String heads = "refs/heads/*";
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().upsertLabelType(verified);
@@ -2522,7 +2690,7 @@
         .project(project)
         .forUpdate()
         .add(allowLabel(verified.getName()).ref(heads).group(CHANGE_OWNER).range(-1, 1))
-        .add(allowLabel("Code-Review").ref(heads).group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref(heads).group(REGISTERED_USERS).range(-2, +2))
         .update();
 
     // Set Code-Review+2 and Verified+1 as admin (change owner)
@@ -2691,7 +2859,7 @@
                 .withOptions(
                     ALL_REVISIONS, CHANGE_ACTIONS, CURRENT_ACTIONS, DETAILED_LABELS, MESSAGES)
                 .get());
-    assertThat(Iterables.getOnlyElement(result.labels.keySet())).isEqualTo("Code-Review");
+    assertThat(Iterables.getOnlyElement(result.labels.keySet())).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(result.messages).hasSize(1);
     assertThat(result.actions).isNotEmpty();
 
@@ -2874,7 +3042,7 @@
   @Test
   public void commitFooters() throws Exception {
     LabelType verified =
-        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     LabelType custom1 =
         label("Custom1", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     LabelType custom2 =
@@ -2902,8 +3070,8 @@
     r2.assertOkStatus();
 
     ReviewInput in = new ReviewInput();
-    in.label("Code-Review", 1);
-    in.label("Verified", 1);
+    in.label(LabelId.CODE_REVIEW, 1);
+    in.label(LabelId.VERIFIED, 1);
     in.label("Custom1", -1);
     in.label("Custom2", 1);
     gApi.changes().id(r2.getChangeId()).current().review(in);
@@ -2942,14 +3110,19 @@
   public void customCommitFooters() throws Exception {
     PushOneCommit.Result change = createChange();
     ChangeInfo actual;
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                (newCommitMessage, original, mergeTip, destination) -> {
-                  assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
-                  return newCommitMessage + "Custom: " + destination.branch();
-                })) {
+    ChangeMessageModifier link =
+        new ChangeMessageModifier() {
+          @Override
+          public String onSubmit(
+              String newCommitMessage,
+              RevCommit original,
+              RevCommit mergeTip,
+              BranchNameKey destination) {
+            assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
+            return newCommitMessage + "Custom: " + destination.branch();
+          }
+        };
+    try (Registration registration = extensionRegistry.newRegistration().add(link)) {
       actual = gApi.changes().id(change.getChangeId()).get(ALL_REVISIONS, COMMIT_FOOTERS);
     }
     List<String> footers =
@@ -3006,7 +3179,7 @@
     String triplet = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(triplet).addReviewer(user.username());
     ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    LabelInfo codeReview = c.labels.get("Code-Review");
+    LabelInfo codeReview = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(codeReview.all).hasSize(1);
     ApprovalInfo approval = codeReview.all.get(0);
     assertThat(approval._accountId).isEqualTo(user.id().get());
@@ -3015,11 +3188,15 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
         .update();
 
     c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    codeReview = c.labels.get("Code-Review");
+    codeReview = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(codeReview.all).hasSize(1);
     approval = codeReview.all.get(0);
     assertThat(approval._accountId).isEqualTo(user.id().get());
@@ -3210,8 +3387,8 @@
     PushOneCommit.Result r = createChange();
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
 
     // add new label and assert that it's returned for existing changes
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
@@ -3229,10 +3406,11 @@
         .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review", "Verified");
-    assertPermitted(change, "Code-Review", -2, -1, 0, 1, 2);
-    assertPermitted(change, "Verified", -1, 0, 1);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertThat(change.permittedLabels.keySet())
+        .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertPermitted(change, LabelId.CODE_REVIEW, -2, -1, 0, 1, 2);
+    assertPermitted(change, LabelId.VERIFIED, -1, 0, 1);
 
     // add an approval on the new label
     gApi.changes()
@@ -3256,15 +3434,15 @@
         .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
 
     // abandon the change and see that the returned labels stay the same
     // while all permitted labels disappear.
     gApi.changes().id(r.getChangeId()).abandon();
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels).isEmpty();
   }
 
@@ -3277,9 +3455,9 @@
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(change.submissionId).isNotNull();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
-    assertPermitted(change, "Code-Review", 2);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
 
     LabelType verified = TestLabels.verified();
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
@@ -3297,10 +3475,11 @@
         .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review", "Verified");
-    assertPermitted(change, "Code-Review", 2);
-    assertPermitted(change, "Verified", 0, 1);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertThat(change.permittedLabels.keySet())
+        .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
+    assertPermitted(change, LabelId.VERIFIED, 0, 1);
 
     // ignore the new label by Prolog submit rule and assert that the label is
     // no longer returned
@@ -3316,8 +3495,8 @@
     push2.to(RefNames.REFS_CONFIG);
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertPermitted(change, "Code-Review", 2);
-    assertPermitted(change, "Verified");
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
+    assertPermitted(change, LabelId.VERIFIED);
 
     // add an approval on the new label and assert that the label is now
     // returned although it is ignored by the Prolog submit rule and hence not
@@ -3328,9 +3507,9 @@
         .review(new ReviewInput().label(verified.getName(), verified.getMax().getValue()));
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
-    assertPermitted(change, "Code-Review", 2);
-    assertPermitted(change, "Verified");
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
+    assertPermitted(change, LabelId.VERIFIED);
 
     // remove label and assert that it's no longer returned for existing
     // changes, even if there is an approval for it
@@ -3345,9 +3524,9 @@
         .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
-    assertPermitted(change, "Code-Review", 2);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
   }
 
   @Test
@@ -3440,9 +3619,10 @@
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(change.submissionId).isNotNull();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Non-Author-Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
-    assertPermitted(change, "Code-Review", 0, 1, 2);
+    assertThat(change.labels.keySet())
+        .containsExactly(LabelId.CODE_REVIEW, "Non-Author-Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
   }
 
   @Test
@@ -3456,8 +3636,8 @@
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(change.submissionId).isNotNull();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertPermitted(change, "Code-Review", 0, 1, 2);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
   }
 
   @Test
@@ -3494,7 +3674,7 @@
     gApi.changes().id(triplet).addReviewer(user.username());
 
     ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    LabelInfo codeReview = c.labels.get("Code-Review");
+    LabelInfo codeReview = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(codeReview.all).hasSize(1);
     ApprovalInfo approval = codeReview.all.get(0);
     assertThat(approval._accountId).isEqualTo(user.id().get());
@@ -3507,14 +3687,14 @@
         .project(project)
         .forUpdate()
         .add(
-            allowLabel("Code-Review")
+            allowLabel(LabelId.CODE_REVIEW)
                 .ref(heads)
                 .group(REGISTERED_USERS)
                 .range(minPermittedValue, maxPermittedValue))
         .update();
 
     c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    codeReview = c.labels.get("Code-Review");
+    codeReview = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(codeReview.all).hasSize(1);
     approval = codeReview.all.get(0);
     assertThat(approval._accountId).isEqualTo(user.id().get());
@@ -3528,7 +3708,11 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
         .update();
 
     PushOneCommit.Result r = createChange();
@@ -3537,7 +3721,7 @@
     gApi.changes().id(triplet).addReviewer(user.username());
 
     ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    LabelInfo codeReview = c.labels.get("Code-Review");
+    LabelInfo codeReview = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(codeReview.all).hasSize(1);
     ApprovalInfo approval = codeReview.all.get(0);
     assertThat(approval._accountId).isEqualTo(user.id().get());
@@ -3554,7 +3738,7 @@
 
     Map<String, Short> votes =
         gApi.changes().id(changeId).current().reviewer(admin.email()).votes();
-    assertThat(votes.keySet()).containsExactly("Code-Review");
+    assertThat(votes.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(votes.values()).containsExactly((short) 2);
   }
 
@@ -3563,7 +3747,7 @@
     String changeId = createChange().getChangeId();
 
     // Add a review with invalid label values.
-    ReviewInput input = new ReviewInput().label("Code-Review", 3);
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 3);
     gApi.changes().id(changeId).current().review(input);
 
     assertThrows(
@@ -3587,7 +3771,7 @@
   @GerritConfig(name = "change.strictLabels", value = "true")
   public void strictLabelWithInvalidValue() throws Exception {
     String changeId = createChange().getChangeId();
-    ReviewInput in = new ReviewInput().label("Code-Review", 3);
+    ReviewInput in = new ReviewInput().label(LabelId.CODE_REVIEW, 3);
 
     BadRequestException thrown =
         assertThrows(
@@ -3811,7 +3995,7 @@
   }
 
   private void submittableAfterLosingPermissions(String label) throws Exception {
-    String codeReviewLabel = "Code-Review";
+    String codeReviewLabel = LabelId.CODE_REVIEW;
     AccountGroup.UUID registered = REGISTERED_USERS;
     projectOperations
         .project(project)
@@ -4328,4 +4512,17 @@
   private interface AddReviewerCaller {
     void call(String changeId, String reviewer) throws RestApiException;
   }
+
+  private static class TestWorkInProgressStateChangedListener
+      implements WorkInProgressStateChangedListener {
+    boolean invoked;
+    Boolean wip;
+
+    @Override
+    public void onWorkInProgressStateChanged(Event event) {
+      this.invoked = true;
+      this.wip =
+          event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
index eb5b9b0..c6b57da 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
@@ -19,13 +19,14 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.SubmitRule;
@@ -35,13 +36,17 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.junit.Test;
 
 public class ChangeSubmitRequirementIT extends AbstractDaemonTest {
-  private static final SubmitRequirement req =
-      SubmitRequirement.builder().setType("custom_rule").setFallbackText("Fallback text").build();
-  private static final SubmitRequirementInfo reqInfo =
-      new SubmitRequirementInfo("NOT_READY", "Fallback text", "custom_rule");
+  private static final LegacySubmitRequirement req =
+      LegacySubmitRequirement.builder()
+          .setType("custom_rule")
+          .setFallbackText("Fallback text")
+          .build();
+  private static final LegacySubmitRequirementInfo reqInfo =
+      new LegacySubmitRequirementInfo("NOT_READY", "Fallback text", "custom_rule");
 
   @Override
   public Module createModule() {
@@ -55,7 +60,7 @@
     };
   }
 
-  @Inject CustomSubmitRule rule;
+  @Inject private CustomSubmitRule rule;
 
   @Test
   public void submitRequirementIsPropagated() throws Exception {
@@ -167,6 +172,35 @@
     assertThat(result.get(0).changeId).isEqualTo(change.info().changeId);
   }
 
+  @Test
+  public void submitRuleIsInvokedOnlyOnceWhenGettingChangeDetails() throws Exception {
+    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
+    String changeId = r.getChangeId();
+
+    rule.numberOfEvaluations.set(0);
+    gApi.changes()
+        .id(changeId)
+        .get(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_ACTIONS);
+
+    // Submit rules are computed freshly, but only once.
+    assertThat(rule.numberOfEvaluations.get()).isEqualTo(1);
+  }
+
+  @Test
+  public void submitRuleIsNotInvokedWhenQueryingChange() 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.ALL_REVISIONS, ListChangesOption.CURRENT_ACTIONS)
+        .get();
+
+    // Submit rule evaluation results from the change index are reused
+    assertThat(rule.numberOfEvaluations.get()).isEqualTo(0);
+  }
+
   private List<ChangeInfo> queryIsSubmittable() throws Exception {
     return gApi.changes().query("is:submittable project:" + project.get()).get();
   }
@@ -186,6 +220,7 @@
   @Singleton
   private static class CustomSubmitRule implements SubmitRule {
     private Optional<SubmitRecord.Status> recordStatus = Optional.empty();
+    private AtomicInteger numberOfEvaluations = new AtomicInteger();
 
     public void block(boolean block) {
       this.status(block ? Optional.of(SubmitRecord.Status.NOT_READY) : Optional.empty());
@@ -197,6 +232,7 @@
 
     @Override
     public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+      numberOfEvaluations.incrementAndGet();
       if (this.recordStatus.isPresent()) {
         SubmitRecord record = new SubmitRecord();
         record.labels = new ArrayList<>();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
index 31198d5..cebce0b 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
@@ -16,9 +16,6 @@
 
 import com.google.gerrit.acceptance.AbstractPluginFieldsTest;
 import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.extensions.annotations.Exports;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
-import com.google.inject.AbstractModule;
 import java.util.Arrays;
 import org.junit.Test;
 
@@ -27,30 +24,6 @@
   // No tests for /detail via the extension API, since the extension API doesn't have that method.
 
   @Test
-  public void queryChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()));
-  }
-
-  @Test
-  public void getChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()));
-  }
-
-  @Test
-  public void queryChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()));
-  }
-
-  @Test
-  public void getChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()));
-  }
-
-  @Test
   public void querySingleChangeWithBulkAttribute() throws Exception {
     getSingleChangeWithPluginDefinedBulkAttribute(
         id -> pluginInfosFromChangeInfos(gApi.changes().query(id.toString()).get()));
@@ -63,22 +36,6 @@
   }
 
   @Test
-  public void queryChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()),
-        (id, opts) ->
-            pluginInfoFromSingletonList(
-                gApi.changes().query(id.toString()).withPluginOptions(opts).get()));
-  }
-
-  @Test
-  public void getChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get()),
-        (id, opts) -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get(opts)));
-  }
-
-  @Test
   public void queryChangeWithOptionBulkAttribute() throws Exception {
     getChangeWithPluginDefinedBulkAttributeOption(
         id -> pluginInfosFromChangeInfos(gApi.changes().query(id.toString()).get()),
@@ -108,12 +65,6 @@
   }
 
   @Test
-  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
-    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
-        () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
-  }
-
-  @Test
   public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
     getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
         () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
@@ -124,23 +75,4 @@
     getChangeWithPluginDefinedBulkAttributeWithException(
         id -> pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.get()).get())));
   }
-
-  static class SimpleAttributeWithExplicitExportModule extends AbstractModule {
-    @Override
-    public void configure() {
-      bind(ChangeAttributeFactory.class)
-          .annotatedWith(Exports.named("simple"))
-          .toInstance((cd, bp, p) -> new MyInfo("change " + cd.getId()));
-    }
-  }
-
-  @Test
-  public void getChangeWithSimpleAttributeWithExplicitExport() throws Exception {
-    // For backwards compatibility with old plugins, allow modules to bind into the
-    // DynamicSet<ChangeAttributeFactory> as if it were a DynamicMap. We only need one variant of
-    // this test to prove that the mapping works.
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()),
-        SimpleAttributeWithExplicitExportModule.class);
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 7d73374..c8b1715 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -29,9 +29,17 @@
 import com.google.common.collect.Iterables;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -44,11 +52,15 @@
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.PostReview;
 import com.google.gerrit.server.update.CommentsRejectedException;
 import com.google.gerrit.testing.TestCommentHelper;
@@ -58,6 +70,7 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
@@ -69,6 +82,7 @@
   @Inject private CommentValidator mockCommentValidator;
   @Inject private TestCommentHelper testCommentHelper;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   private static final String COMMENT_TEXT = "The comment text";
   private static final CommentForValidation FILE_COMMENT_FOR_VALIDATION =
@@ -447,7 +461,7 @@
     // User adds themselves and changes state
     requestScopeOperations.setApiUser(user.id());
 
-    ReviewInput input = new ReviewInput().label("Code-Review", 1);
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
     gApi.changes().id(r.getChangeId()).current().review(input);
 
     Map<ReviewerState, Collection<AccountInfo>> reviewers =
@@ -474,6 +488,204 @@
     assertThat(reviewer._accountId).isEqualTo(user.id().get());
   }
 
+  @Test
+  public void extendChangeMessageFromPlugin() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    String testMessage = "hello from plugin";
+    TestOnPostReview testOnPostReview = new TestOnPostReview(testMessage);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages;
+      assertThat(Iterables.getLast(messages).message)
+          .isEqualTo(String.format("Patch Set 1: Code-Review+1\n\n%s\n", testMessage));
+    }
+  }
+
+  @Test
+  public void extendChangeMessageFromMultiplePlugins() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    String testMessage1 = "hello from plugin 1";
+    String testMessage2 = "message from plugin 2";
+    TestOnPostReview testOnPostReview1 = new TestOnPostReview(testMessage1);
+    TestOnPostReview testOnPostReview2 = new TestOnPostReview(testMessage2);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testOnPostReview1).add(testOnPostReview2)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages;
+      assertThat(Iterables.getLast(messages).message)
+          .isEqualTo(
+              String.format(
+                  "Patch Set 1: Code-Review+1\n\n%s\n\n%s\n", testMessage1, testMessage2));
+    }
+  }
+
+  @Test
+  public void onPostReviewExtensionThatDoesntExtendTheChangeMessage() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages;
+      assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Code-Review+1");
+    }
+  }
+
+  @Test
+  public void onPostReviewCallbackGetsCorrectChangeAndPatchSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    amendChange(r.getChangeId());
+
+    TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+
+      // Vote on current patch set.
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertChangeAndPatchSet(r.getChange().getId(), 2);
+
+      // Vote on old patch set.
+      gApi.changes().id(r.getChangeId()).revision(1).review(input);
+      testOnPostReview.assertChangeAndPatchSet(r.getChange().getId(), 1);
+    }
+  }
+
+  @Test
+  public void onPostReviewCallbackGetsCorrectUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+
+      // Vote from admin.
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertUser(admin);
+
+      // Vote from user.
+      requestScopeOperations.setApiUser(user.id());
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertUser(user);
+    }
+  }
+
+  @Test
+  public void onPostReviewCallbackGetsCorrectApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      // Add a new vote.
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertApproval(
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ 0, /* expectedNewValue= */ 1);
+
+      // Update an existing vote.
+      input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertApproval(
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ 1, /* expectedNewValue= */ 2);
+
+      // Post without changing the vote.
+      input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertApproval(
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ null, /* expectedNewValue= */ 2);
+
+      // Delete the vote.
+      input = new ReviewInput().label(LabelId.CODE_REVIEW, 0);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertApproval(
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ 2, /* expectedNewValue= */ 0);
+    }
+  }
+
+  @Test
+  public void votingTheSameVoteSecondTime() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    sender.clear();
+
+    // Add a new vote.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(r.getChange().approvals().values()).hasSize(1);
+
+    // Post without changing the vote.
+    input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Second vote replaced the original vote, so still only one vote.
+    assertThat(r.getChange().approvals().values()).hasSize(1);
+    List<ChangeMessageInfo> changeMessages = gApi.changes().id(r.getChangeId()).messages();
+
+    // Only the last change message is about Code-Review+2
+    assertThat(Iterables.getLast(changeMessages).message).isEqualTo("Patch Set 1: Code-Review+2");
+    changeMessages.remove(changeMessages.size() - 1);
+    assertThat(Iterables.getLast(changeMessages).message)
+        .isNotEqualTo("Patch Set 1: Code-Review+2");
+
+    // Only one email is about Code-Review +2 was sent.
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains("Patch Set 1: Code-Review+2");
+  }
+
+  @Test
+  public void votingTheSameVoteSecondTimeExtendsOnPostReviewWithOldNullValue() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(r.getChange().approvals().values()).hasSize(1);
+
+    TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      // Post without changing the vote.
+      input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+
+      testOnPostReview.assertApproval(
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ null, /* expectedNewValue= */ 2);
+    }
+  }
+
+  @Test
+  public void votingTheSameVoteSecondTimeDoesNotFireOnCommentAdded() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(r.getChange().approvals().values()).hasSize(1);
+
+    TestListener testListener = new TestListener();
+    try (Registration registration = extensionRegistry.newRegistration().add(testListener)) {
+      // Post without changing the vote.
+      input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+
+      // Event not fired.
+      assertThat(testListener.lastCommentAddedEvent).isNull();
+    }
+  }
+
+  private static class TestListener implements CommentAddedListener {
+    public CommentAddedListener.Event lastCommentAddedEvent;
+
+    @Override
+    public void onCommentAdded(Event event) {
+      lastCommentAddedEvent = event;
+    }
+  }
+
   private List<RobotCommentInfo> getRobotComments(String changeId) throws RestApiException {
     return gApi.changes().id(changeId).robotComments().values().stream()
         .flatMap(Collection::stream)
@@ -495,4 +707,50 @@
         .comparingElementsUsing(COMMENT_CORRESPONDENCE)
         .containsExactly(commentsForValidation);
   }
+
+  private static class TestOnPostReview implements OnPostReview {
+    private final Optional<String> message;
+
+    private Change.Id changeId;
+    private PatchSet.Id patchSetId;
+    private Account.Id accountId;
+    private Map<String, Short> oldApprovals;
+    private Map<String, Short> approvals;
+
+    TestOnPostReview(@Nullable String message) {
+      this.message = Optional.ofNullable(message);
+    }
+
+    @Override
+    public Optional<String> getChangeMessageAddOn(
+        IdentifiedUser user,
+        ChangeNotes changeNotes,
+        PatchSet patchSet,
+        Map<String, Short> oldApprovals,
+        Map<String, Short> approvals) {
+      this.changeId = changeNotes.getChangeId();
+      this.patchSetId = patchSet.id();
+      this.accountId = user.getAccountId();
+      this.oldApprovals = oldApprovals;
+      this.approvals = approvals;
+      return message;
+    }
+
+    public void assertChangeAndPatchSet(Change.Id expectedChangeId, int expectedPatchSetNum) {
+      assertThat(changeId).isEqualTo(expectedChangeId);
+      assertThat(patchSetId.get()).isEqualTo(expectedPatchSetNum);
+    }
+
+    public void assertUser(TestAccount expectedUser) {
+      assertThat(accountId).isEqualTo(expectedUser.id());
+    }
+
+    public void assertApproval(
+        String labelName, @Nullable Integer expectedOldValue, int expectedNewValue) {
+      assertThat(oldApprovals)
+          .containsExactly(
+              labelName, expectedOldValue != null ? expectedOldValue.shortValue() : null);
+      assertThat(approvals).containsExactly(labelName, (short) expectedNewValue);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index a3a089f..6cf3f3e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -290,8 +290,28 @@
     gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
     RevertInput revertInput = new RevertInput();
     revertInput.message = "Message from input";
-    assertThat(gApi.changes().id(result.getChangeId()).revert(revertInput).get().subject)
-        .isEqualTo(revertInput.message);
+    ChangeInfo revertChange = gApi.changes().id(result.getChangeId()).revert(revertInput).get();
+    assertThat(revertChange.subject).isEqualTo(revertInput.message);
+    assertThat(gApi.changes().id(revertChange.id).current().commit(false).message)
+        .isEqualTo(String.format("Message from input\n\nChange-Id: %s\n", revertChange.changeId));
+  }
+
+  @Test
+  public void revertWithSetMessageChangeIdIgnored() throws Exception {
+    PushOneCommit.Result result = createChange();
+    gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
+    RevertInput revertInput = new RevertInput();
+    String fakeChangeId = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    String commitSubject = "Message from input";
+    revertInput.message = String.format("%s\n\nChange-Id: %s\n", commitSubject, fakeChangeId);
+    ChangeInfo revertChange = gApi.changes().id(result.getChangeId()).revert(revertInput).get();
+    // ChangeId provided in revert input is ignored.
+    assertThat(revertChange.changeId).isNotEqualTo(fakeChangeId);
+    assertThat(revertChange.subject).isEqualTo(commitSubject);
+    // ChangeId footer was replaced in revert commit message.
+    assertThat(gApi.changes().id(revertChange.id).current().commit(false).message)
+        .isEqualTo(String.format("Message from input\n\nChange-Id: %s\n", revertChange.changeId));
   }
 
   @Test
@@ -825,6 +845,38 @@
   }
 
   @Test
+  public void revertSubmissionWithSetMessageChangeIdIgnored() throws Exception {
+    String firstResult = createChange("first change", "a.txt", "message").getChangeId();
+    String secondResult = createChange("second change", "b.txt", "message").getChangeId();
+    approve(firstResult);
+    approve(secondResult);
+    gApi.changes().id(secondResult).current().submit();
+    RevertInput revertInput = new RevertInput();
+    String fakeChangeId = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    String commitSubject = "Message from input";
+    String revertMessage = String.format("%s\n\nChange-Id: %s\n", commitSubject, fakeChangeId);
+    revertInput.message = revertMessage;
+    List<ChangeInfo> revertChanges =
+        gApi.changes().id(firstResult).revertSubmission(revertInput).revertChanges;
+    assertThat(revertChanges.get(0).subject).isEqualTo("Revert \"first change\"");
+    // ChangeId provided in revert input is ignored.
+    assertThat(revertChanges.get(0).changeId).isNotEqualTo(fakeChangeId);
+    assertThat(revertChanges.get(1).changeId).isNotEqualTo(fakeChangeId);
+    // ChangeId footer was replaced in revert commit message.
+    assertThat(gApi.changes().id(revertChanges.get(0).id).current().commit(false).message)
+        .isEqualTo(
+            String.format(
+                "Revert \"first change\"\n\n%s\n\nChange-Id: %s\n",
+                commitSubject, revertChanges.get(0).changeId));
+    assertThat(revertChanges.get(1).subject).isEqualTo("Revert \"second change\"");
+    assertThat(gApi.changes().id(revertChanges.get(1).id).current().commit(false).message)
+        .isEqualTo(
+            String.format(
+                "Revert \"second change\"\n\n%s\n\nChange-Id: %s\n",
+                commitSubject, revertChanges.get(1).changeId));
+  }
+
+  @Test
   public void revertSubmissionWithoutMessage() throws Exception {
     String firstResult = createChange("first change", "a.txt", "message").getChangeId();
     String secondResult = createChange("second change", "b.txt", "message").getChangeId();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 58ea6ea..4e47bb1 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -37,9 +37,14 @@
 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.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -68,6 +73,7 @@
 public class StickyApprovalsIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ChangeOperations changeOperations;
 
   @Inject
   @Named("change_kind")
@@ -80,7 +86,7 @@
       // This way changes to the "Code Review" label don't affect other tests.
       LabelType.Builder codeReview =
           labelBuilder(
-              "Code-Review",
+              LabelId.CODE_REVIEW,
               value(2, "Looks good to me, approved"),
               value(1, "Looks good to me, but someone else must approve"),
               value(0, "No score"),
@@ -90,7 +96,8 @@
       u.getConfig().upsertLabelType(codeReview.build());
 
       LabelType.Builder verified =
-          labelBuilder("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
       verified.setCopyAllScoresIfNoChange(false);
       u.getConfig().upsertLabelType(verified.build());
 
@@ -121,7 +128,7 @@
   @Test
   public void stickyOnAnyScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAnyScore(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
       u.save();
     }
 
@@ -143,7 +150,7 @@
   @Test
   public void stickyOnMinScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMinScore(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMinScore(true));
       u.save();
     }
 
@@ -165,7 +172,7 @@
   @Test
   public void stickyOnMaxScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
       u.save();
     }
 
@@ -191,7 +198,7 @@
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
           .updateLabelType(
-              "Code-Review", b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
+              LabelId.CODE_REVIEW, b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
       u.save();
     }
 
@@ -215,7 +222,8 @@
   @Test
   public void stickyOnTrivialRebase() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAllScoresOnTrivialRebase(true));
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnTrivialRebase(true));
       u.save();
     }
 
@@ -261,7 +269,7 @@
   @Test
   public void stickyOnNoCodeChange() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
+      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -286,7 +294,8 @@
   public void stickyOnMergeFirstParentUpdate() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
-          .updateLabelType("Code-Review", b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
       u.save();
     }
 
@@ -310,7 +319,7 @@
   @Test
   public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAllScoresIfNoChange(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfNoChange(true));
       u.save();
     }
 
@@ -325,10 +334,169 @@
   }
 
   @Test
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("new file")
+        .content("new content")
+        .create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // no votes are copied since the list of files changed.
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+
+    // create "existing file" and submit it.
+    String existingFile = "existing file";
+    Change.Id prep =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file(existingFile)
+            .content("content")
+            .create();
+    vote(admin, prep.toString(), 2, 1);
+    gApi.changes().id(prep.get()).current().submit();
+
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file(existingFile)
+        .content("new content")
+        .create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // no votes are copied since the list of files changed ("existing file" was added to the
+    // change).
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").delete().create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // no votes are copied since the list of files changed.
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // only code review votes are copied since copyAllScoresIfListOfFilesDidNotChange is
+    // configured for that label, and list of files didn't change.
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, -2, 0);
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // only code review votes are copied since copyAllScoresIfListOfFilesDidNotChange is
+    // configured for that label, and list of files didn't change.
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, -2, 0);
+  }
+
+  @Test
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").renameTo("new_file").create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // no votes are copied since the list of files changed (rename).
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
   public void removedVotesNotSticky() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAllScoresOnTrivialRebase(true));
-      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnTrivialRebase(true));
+      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -357,8 +525,8 @@
   @Test
   public void stickyAcrossMultiplePatchSets() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -383,8 +551,8 @@
     // change kind against all prior patch sets. This is a regression that made Gerrit do expensive
     // work in O(num-patch-sets). This test ensures that we aren't regressing.
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -415,8 +583,8 @@
   @Test
   public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMinScore(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMinScore(true));
       u.save();
     }
 
@@ -454,7 +622,7 @@
 
   @Test
   public void deleteStickyVote() throws Exception {
-    String label = "Code-Review";
+    String label = LabelId.CODE_REVIEW;
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().updateLabelType(label, b -> b.setCopyMaxScore(true));
       u.save();
@@ -468,10 +636,37 @@
     assertVotes(detailedChange(changeId), admin, label, 2, REWORK);
 
     // Delete vote that was copied via sticky approval
-    deleteVote(admin, changeId, "Code-Review");
+    deleteVote(admin, changeId, label);
     assertVotes(detailedChange(changeId), admin, label, 0, REWORK);
   }
 
+  @Test
+  public void canVoteMultipleTimesOnNewPatchsets() throws Exception {
+    // Code-Review will be sticky.
+    String label = LabelId.CODE_REVIEW;
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
+      u.save();
+    }
+
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Make a new patchset, keeping the Code-Review +2 vote.
+    amendChange(r.getChangeId());
+
+    // Post without changing the vote.
+    input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // There is a vote both on patchset 1 and on patchset 2, although both votes are Code-Review +2.
+    assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 1))).hasSize(1);
+    assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 2))).hasSize(1);
+  }
+
   private void assertChangeKindCacheContains(ObjectId prior, ObjectId next) {
     ChangeKind kind =
         changeKindCache.getIfPresent(ChangeKindCacheImpl.Key.create(prior, next, "recursive"));
@@ -592,7 +787,7 @@
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
     RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    ReviewInput in = new ReviewInput().label("Code-Review", 2).label("Verified", 1);
+    ReviewInput in = new ReviewInput().label(LabelId.CODE_REVIEW, 2).label(LabelId.VERIFIED, 1);
     revision.review(in);
     revision.submit();
 
@@ -704,7 +899,9 @@
       throws Exception {
     requestScopeOperations.setApiUser(user.id());
     ReviewInput in =
-        new ReviewInput().label("Code-Review", codeReviewVote).label("Verified", verifiedVote);
+        new ReviewInput()
+            .label(LabelId.CODE_REVIEW, codeReviewVote)
+            .label(LabelId.VERIFIED, verifiedVote);
     gApi.changes().id(changeId).current().review(in);
   }
 
@@ -719,8 +916,8 @@
 
   private void assertVotes(
       ChangeInfo c, TestAccount user, int codeReviewVote, int verifiedVote, ChangeKind changeKind) {
-    assertVotes(c, user, "Code-Review", codeReviewVote, changeKind);
-    assertVotes(c, user, "Verified", verifiedVote, changeKind);
+    assertVotes(c, user, LabelId.CODE_REVIEW, codeReviewVote, changeKind);
+    assertVotes(c, user, LabelId.VERIFIED, verifiedVote, changeKind);
   }
 
   private void assertVotes(
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
new file mode 100644
index 0000000..bc9f50a5
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.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.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+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.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.inject.Inject;
+import java.util.List;
+import org.junit.Test;
+
+public class SubmitRuleIT extends AbstractDaemonTest {
+  @Inject private SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
+
+  @Test
+  public void submitRecordsForClosedChanges_parsedBackByDefault() throws Exception {
+    SubmitRuleEvaluator submitRuleEvaluator =
+        submitRuleEvaluatorFactory.create(SubmitRuleOptions.defaults());
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+    List<SubmitRecord> recordsBeforeSubmission = submitRuleEvaluator.evaluate(r.getChange());
+    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.
+    setupCustomBlockingLabel();
+    List<SubmitRecord> recordsAfterSubmission = submitRuleEvaluator.evaluate(r.getChange());
+    recordsBeforeSubmission.forEach(
+        sr -> sr.status = SubmitRecord.Status.CLOSED); // Set status to closed
+    assertThat(recordsBeforeSubmission).isEqualTo(recordsAfterSubmission);
+  }
+
+  @Test
+  public void submitRecordsForClosedChanges_recomputedIfRequested() throws Exception {
+    SubmitRuleEvaluator submitRuleEvaluator =
+        submitRuleEvaluatorFactory.create(
+            SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build());
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+    List<SubmitRecord> recordsBeforeSubmission = submitRuleEvaluator.evaluate(r.getChange());
+    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.
+    setupCustomBlockingLabel();
+    List<SubmitRecord> recordsAfterSubmission = submitRuleEvaluator.evaluate(r.getChange());
+    assertThat(recordsBeforeSubmission).isNotEqualTo(recordsAfterSubmission);
+    assertThat(recordsAfterSubmission).hasSize(1);
+    List<SubmitRecord.Label> recordLabels = recordsAfterSubmission.get(0).labels;
+
+    assertThat(recordLabels).hasSize(2);
+    assertCodeReviewApproved(recordLabels);
+    assertMyLabelNeed(recordLabels);
+  }
+
+  private void assertCodeReviewApproved(List<SubmitRecord.Label> recordLabels) {
+    SubmitRecord.Label haveCodeReview = new SubmitRecord.Label();
+    haveCodeReview.label = "Code-Review";
+    haveCodeReview.status = SubmitRecord.Label.Status.OK;
+    haveCodeReview.appliedBy = admin.id();
+    assertThat(recordLabels).contains(haveCodeReview);
+  }
+
+  private void assertMyLabelNeed(List<SubmitRecord.Label> recordLabels) {
+    SubmitRecord.Label needCustomLabel = new SubmitRecord.Label();
+    needCustomLabel.label = "My-Label";
+    needCustomLabel.status = SubmitRecord.Label.Status.NEED;
+    assertThat(recordLabels).contains(needCustomLabel);
+  }
+
+  private void setupCustomBlockingLabel() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .upsertLabelType(
+              LabelType.builder(
+                      "My-Label",
+                      ImmutableList.of(
+                          LabelValue.create((short) 0, "Not approved"),
+                          LabelValue.create((short) 1, "Approved")))
+                  .setFunction(LabelFunction.MAX_WITH_BLOCK)
+                  .build());
+      u.save();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
new file mode 100644
index 0000000..5ca7310
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
@@ -0,0 +1,399 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.server.patch.filediff.Edit;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.inject.Inject;
+import java.util.Iterator;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class SubmitWithStickyApprovalDiffIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private ChangeOperations changeOperations;
+
+  @Before
+  public void setup() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      // Overwrite "Code-Review" label that is inherited from All-Projects.
+      // This way changes to the "Code Review" label don't affect other tests.
+      // Also make the vote sticky.
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer that you didn't submit this"),
+              value(-2, "Do not submit"));
+      codeReview.setCopyAnyScore(true);
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_modifiedFileWithReplaces() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content\naa\nsF\naa\naaa\nsomething\nfoo\nbla\ndeletedEnd")
+            .create();
+
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("file")
+        .content("content\naa\nsS\naa\naaa\ndifferent\nfoo\nbla")
+        .create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "file",
+        /* insertions= */ 3,
+        /* deletions= */ 4,
+        /* edits= */ ImmutableList.of(
+            Edit.create(2, 3, 2, 3), Edit.create(5, 6, 5, 6), Edit.create(7, 9, 7, 8)),
+        /* previousLines= */ ImmutableList.of(
+            "-  sF\n", "-  something\n", "-  bla\n-  " + "deletedEnd\n"),
+        /* newLines= */ ImmutableList.of("+  sS\n", "+  different\n", "+  bla\n"),
+        /* oldFileName= */ null);
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_modifiedFileWithInsertionAndDeletion()
+      throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content\naa\nbb\ncc" + "\ndd\nee\nff\nTODELETE1\nTODELETE2\ngg\nend")
+            .create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("file")
+        .content("content\naa\nbb\ncc\nINSERTION\nINSERTED\nVERY\nLONG\ndd\nee\nff\ngg\nend")
+        .create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "file",
+        /* insertions= */ 4,
+        /* deletions= */ 2,
+        /* edits= */ ImmutableList.of(Edit.create(4, 4, 4, 8), Edit.create(7, 9, 7, 7)),
+        /* previousLines= */ ImmutableList.of("-  TODELETE1\n-  TODELETE2\n"),
+        /* newLines= */ ImmutableList.of("+  INSERTION\n+  INSERTED\n+  VERY\n+  LONG\n"),
+        /* oldFileName= */ null);
+  }
+
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
+  public void autoGeneratedPostSubmitDiffIsNotPartOfTheCommentSizeLimit() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+    String content = new String(new char[800]).replace("\0", "a");
+    changeOperations.change(changeId).newPatchset().file("file").content(content).create();
+
+    // Post a submit diff that is almost the cumulativeCommentSizeLimit
+    gApi.changes().id(changeId.get()).current().submit();
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .doesNotContain("many unreviewed changes");
+
+    // unrelated comment and change message posting works fine, since the post submit diff is not
+    // counted towards the cumulativeCommentSizeLimit for unrelated follow-up comments.
+    // 800 + 400 + 400 > 1k, but 400 + 400 < 1k, hence these comments are accepted (the original
+    // 800 is not counted).
+    String message = new String(new char[400]).replace("\0", "a");
+    ReviewInput reviewInput = new ReviewInput().message(message);
+    CommentInput commentInput = new CommentInput();
+    commentInput.line = 1;
+    commentInput.message = message;
+    commentInput.path = "file";
+    reviewInput.comments = ImmutableMap.of("file", ImmutableList.of(commentInput));
+
+    gApi.changes().id(changeId.get()).current().review(reviewInput);
+  }
+
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
+  public void postSubmitDiffCannotBeTooBig() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    String content = new String(new char[1100]).replace("\0", "a");
+
+    changeOperations.change(changeId).newPatchset().file("file").content(content).create();
+
+    // Post submit diff is over the cumulativeCommentSizeLimit, so we shorten the message.
+    gApi.changes().id(changeId.get()).current().submit();
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .isEqualTo(
+            "Change has been successfully merged\n\n1 is the latest approved patch-set.\nThe "
+                + "change was submitted "
+                + "with many unreviewed changes (the diff is too large to show). Please review the "
+                + "diff.");
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_addedFile() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("file")
+        .content("content\nmore content\nlast content")
+        .create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "file",
+        /* insertions= */ 3,
+        /* deletions= */ 0,
+        /* edits= */ ImmutableList.of(Edit.create(0, 0, 0, 3)),
+        /* previousLines= */ ImmutableList.of(),
+        /* newLines= */ ImmutableList.of("+  content\n+  more content\n+  last content\n"),
+        /* oldFileName= */ null);
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_removedFile() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content\nmore content\nlast content")
+            .create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations.change(changeId).newPatchset().file("file").delete().create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "file",
+        /* insertions= */ 0,
+        /* deletions= */ 3,
+        /* edits= */ ImmutableList.of(Edit.create(0, 3, 0, 0)),
+        /* previousLines= */ ImmutableList.of("-  content\n-  more content\n-  last content\n"),
+        /* newLines= */ ImmutableList.of(),
+        /* oldFileName= */ null);
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_renamedFile() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content\nmoreContent")
+            .create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations.change(changeId).newPatchset().file("file").renameTo("new_file").create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "new_file",
+        /* insertions= */ 0,
+        /* deletions= */ 0,
+        /* edits= */ ImmutableList.of(),
+        /* previousLines= */ ImmutableList.of(),
+        /* newLines= */ ImmutableList.of(),
+        /* oldFileName= */ "file");
+  }
+
+  @Test
+  public void noDiffChangeMessageOnSubmitWhenVotedOnLastPatchset() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content\nmoreContent")
+            .create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations.change(changeId).newPatchset().file("file").renameTo("new_file").create();
+
+    // Approve last patch-set again, although there is already a +2 on the change (since it's
+    // sticky).
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message.trim())
+        .isEqualTo("Change has been successfully merged");
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_approvedPatchset() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    changeOperations.change(changeId).newPatchset().create();
+
+    // approve patch-set 2
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    // create patch-set 3
+    changeOperations.change(changeId).newPatchset().create();
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    // patch-set 2 was the latest approved one.
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .contains("2 is the latest approved patch-set.");
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_noChanges() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    // no file changed
+    changeOperations.change(changeId).newPatchset().create();
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    // No other content in the message since the diff is the same.
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .isEqualTo(
+            "Change has been successfully merged\n\n1 is the latest approved patch-set.\n"
+                + "No files were changed between the latest approved patch-set and the submitted"
+                + " one.\n");
+  }
+
+  private void assertDiffChangeMessageAndEmailWithStickyApproval(
+      String message,
+      String file,
+      int insertions,
+      int deletions,
+      List<Edit> edits,
+      List<String> previousLines,
+      List<String> newLines,
+      String oldFileName) {
+    String expectedMessage =
+        "1 is the latest approved patch-set.\n"
+            + "The change was submitted with unreviewed changes in the following files:\n"
+            + "\n"
+            + String.format("The name of the file: %s\n", file)
+            + String.format("Insertions: %d, Deletions: %d.\n\n", insertions, deletions);
+
+    if (oldFileName != null) {
+      expectedMessage += String.format("The file %s was renamed to %s\n", oldFileName, file);
+    }
+
+    Iterator<String> previousLinesIterator = previousLines.iterator();
+    Iterator<String> newLinesIterator = newLines.iterator();
+    if (!edits.isEmpty()) {
+      expectedMessage += "```\n";
+    }
+    for (Edit edit : edits) {
+      if (edit.beginA() == edit.endA()) {
+        // Insertion
+        expectedMessage += String.format("@@ +%d:%d @@\n", edit.beginB(), edit.endB());
+        expectedMessage += newLinesIterator.next();
+        expectedMessage += "\n";
+        continue;
+      }
+      if (edit.beginB() == edit.endB()) {
+        // Deletion
+        expectedMessage += String.format("@@ -%d:%d @@\n", edit.beginA(), edit.endA());
+        expectedMessage += previousLinesIterator.next();
+        expectedMessage += "\n";
+        continue;
+      }
+      // Replace
+      expectedMessage +=
+          String.format(
+              "@@ -%d:%d, +%d:%d @@\n", edit.beginA(), edit.endA(), edit.beginB(), edit.endB());
+      expectedMessage += previousLinesIterator.next();
+      expectedMessage += newLinesIterator.next();
+      expectedMessage += "\n";
+    }
+    if (!edits.isEmpty()) {
+      expectedMessage += "```\n";
+    }
+    String expectedChangeMessage = "Change has been successfully merged\n\n" + expectedMessage;
+    assertThat(message.trim()).isEqualTo(expectedChangeMessage.trim());
+    assertThat(Iterables.getLast(sender.getMessages()).body()).contains(expectedMessage);
+    assertThat(Iterables.getLast(sender.getMessages()).htmlBody()).contains(expectedMessage);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/BUILD b/javatests/com/google/gerrit/acceptance/api/group/BUILD
index 1ba1138..8530a92 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/group/BUILD
@@ -7,6 +7,7 @@
     labels = ["api"],
     deps = [
         ":util",
+        "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/server/group/db/testing",
         "//java/com/google/gerrit/server/group/testing",
         "//java/com/google/gerrit/server/util/time",
@@ -19,6 +20,7 @@
     name = "util",
     srcs = ["GroupAssert.java"],
     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/GroupAssert.java b/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
index dd891ce..079d43e 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
@@ -17,9 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.server.group.InternalGroup;
 import java.util.Set;
 
 public class GroupAssert {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
index 41ae370..87bdee4 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
@@ -21,13 +21,13 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.GroupInfo;
 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.InternalGroup;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.group.testing.InternalGroupSubject;
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 8dbec28..a277f26 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -54,11 +54,14 @@
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.auth.ldap.FakeLdapGroupBackend;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -85,8 +88,7 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupIncludeCache;
 import com.google.gerrit.server.account.GroupsSnapshotReader;
-import com.google.gerrit.server.auth.ldap.FakeLdapGroupBackend;
-import com.google.gerrit.server.group.InternalGroup;
+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.Groups;
@@ -916,7 +918,9 @@
   @Test
   public void defaultGroupsCreated() throws Exception {
     Iterable<String> names = gApi.groups().list().getAsMap().keySet();
-    assertThat(names).containsAtLeast("Administrators", "Service Users").inOrder();
+    assertThat(names)
+        .containsAtLeast("Administrators", ServiceUserClassifier.SERVICE_USERS)
+        .inOrder();
   }
 
   @Test
@@ -1547,7 +1551,7 @@
         .project(project)
         .forUpdate()
         .add(
-            allowLabel("Code-Review")
+            allowLabel(LabelId.CODE_REVIEW)
                 .ref(RefNames.REFS_GROUPS + "*")
                 .group(REGISTERED_USERS)
                 .range(-2, 2))
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
index 6838f8d..18eca27 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -48,18 +48,14 @@
 @NoHttpd
 public class PluginIT extends AbstractDaemonTest {
   private static final String JS_PLUGIN = "Gerrit.install(function(self){});\n";
-  private static final String HTML_PLUGIN =
-      String.format("<dom-module id=\"test\"><script>%s</script></dom-module>", JS_PLUGIN);
   private static final RawInput JS_PLUGIN_CONTENT = RawInputUtil.create(JS_PLUGIN.getBytes(UTF_8));
-  private static final RawInput HTML_PLUGIN_CONTENT =
-      RawInputUtil.create(HTML_PLUGIN.getBytes(UTF_8));
 
   private static final ImmutableList<String> PLUGINS =
       ImmutableList.of(
           "plugin-a.js",
-          "plugin-b.html",
+          "plugin-b.js",
           "plugin-c.js",
-          "plugin-d.html",
+          "plugin-d.js",
           "plugin-normal.jar",
           "plugin-empty.jar",
           "plugin-unset.jar",
@@ -97,7 +93,7 @@
     assertPlugins(list().start(1).limit(2).get(), PLUGINS.subList(1, 3));
 
     // With prefix
-    assertPlugins(list().prefix("plugin-b").get(), ImmutableList.of("plugin-b.html"));
+    assertPlugins(list().prefix("plugin-b").get(), ImmutableList.of("plugin-b.js"));
     assertPlugins(list().prefix("PLUGIN-").get(), ImmutableList.of());
 
     // With substring
@@ -105,7 +101,7 @@
     assertPlugins(list().substring("lugin-").start(1).limit(2).get(), PLUGINS.subList(1, 3));
 
     // With regex
-    assertPlugins(list().regex(".*in-b").get(), ImmutableList.of("plugin-b.html"));
+    assertPlugins(list().regex(".*in-b").get(), ImmutableList.of("plugin-b.js"));
     assertPlugins(list().regex("plugin-.*").get(), PLUGINS.subList(0, PLUGINS.size() - 1));
     assertPlugins(list().regex("plugin-.*").start(1).limit(2).get(), PLUGINS.subList(1, 3));
 
@@ -147,7 +143,7 @@
     com.google.gerrit.extensions.common.InstallPluginInput input =
         new com.google.gerrit.extensions.common.InstallPluginInput();
     input.raw = JS_PLUGIN_CONTENT;
-    gApi.plugins().install("legacy.html", input);
+    gApi.plugins().install("legacy.js", input);
     gApi.plugins().name("legacy").get();
   }
 
@@ -198,9 +194,6 @@
     if (plugin.endsWith(".js")) {
       return JS_PLUGIN_CONTENT;
     }
-    if (plugin.endsWith(".html")) {
-      return HTML_PLUGIN_CONTENT;
-    }
     assertThat(plugin).endsWith(".jar");
     return pluginJarContent(plugin);
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index f1d537f..45a895a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -19,14 +19,17 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
@@ -36,9 +39,11 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 import java.util.List;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
@@ -48,6 +53,7 @@
 public class CheckAccessIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private GroupOperations groupOperations;
+  @Inject private AllProjectsName allProjectsName;
 
   private Project.NameKey normalProject;
   private Project.NameKey secretProject;
@@ -162,28 +168,37 @@
     String project;
     String permission;
     int want;
+    List<String> expectedDebugLogs;
 
-    static TestCase project(String mail, String project, int want) {
+    static TestCase project(String mail, String project, int want, List<String> expectedDebugLogs) {
       TestCase t = new TestCase();
       t.input = new AccessCheckInput();
       t.input.account = mail;
       t.project = project;
       t.want = want;
+      t.expectedDebugLogs = expectedDebugLogs;
       return t;
     }
 
-    static TestCase projectRef(String mail, String project, String ref, int want) {
+    static TestCase projectRef(
+        String mail, String project, String ref, int want, List<String> expectedDebugLogs) {
       TestCase t = new TestCase();
       t.input = new AccessCheckInput();
       t.input.account = mail;
       t.input.ref = ref;
       t.project = project;
       t.want = want;
+      t.expectedDebugLogs = expectedDebugLogs;
       return t;
     }
 
     static TestCase projectRefPerm(
-        String mail, String project, String ref, String permission, int want) {
+        String mail,
+        String project,
+        String ref,
+        String permission,
+        int want,
+        List<String> expectedDebugLogs) {
       TestCase t = new TestCase();
       t.input = new AccessCheckInput();
       t.input.account = mail;
@@ -191,6 +206,7 @@
       t.input.permission = permission;
       t.project = project;
       t.want = want;
+      t.expectedDebugLogs = expectedDebugLogs;
       return t;
     }
   }
@@ -212,32 +228,115 @@
   public void accessible() throws Exception {
     List<TestCase> inputs =
         ImmutableList.of(
+            // Test 1
             TestCase.projectRefPerm(
                 user.email(),
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.VIEW_PRIVATE_CHANGES,
-                403),
-            TestCase.project(user.email(), normalProject.get(), 200),
-            TestCase.project(user.email(), secretProject.get(), 403),
+                403,
+                ImmutableList.of(
+                    "'user' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'user' cannot perform 'viewPrivateChanges' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/master'")),
+            // Test 2
+            TestCase.project(
+                user.email(),
+                normalProject.get(),
+                200,
+                ImmutableList.of(
+                    "'user' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/*'")),
+            // Test 3
+            TestCase.project(
+                user.email(),
+                secretProject.get(),
+                403,
+                ImmutableList.of(
+                    "'user' cannot perform 'read' with force=false on project '"
+                        + secretProject.get()
+                        + "' for ref 'refs/heads/*' because this permission is blocked",
+                    "'user' cannot perform 'read' with force=false on project '"
+                        + secretProject.get()
+                        + "' for ref 'refs/meta/version' because this permission is blocked")),
+            // Test 4
             TestCase.projectRef(
-                user.email(), secretRefProject.get(), "refs/heads/secret/master", 403),
+                user.email(),
+                secretRefProject.get(),
+                "refs/heads/secret/master",
+                403,
+                ImmutableList.of(
+                    "'user' can perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'user' cannot perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/secret/master' because this permission is blocked")),
+            // Test 5
             TestCase.projectRef(
-                privilegedUser.email(), secretRefProject.get(), "refs/heads/secret/master", 200),
-            TestCase.projectRef(privilegedUser.email(), normalProject.get(), null, 200),
-            TestCase.projectRef(privilegedUser.email(), secretProject.get(), null, 200),
+                privilegedUser.email(),
+                secretRefProject.get(),
+                "refs/heads/secret/master",
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/secret/master'")),
+            // Test 6
+            TestCase.projectRef(
+                privilegedUser.email(),
+                normalProject.get(),
+                null,
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/*'")),
+            // Test 7
+            TestCase.projectRef(
+                privilegedUser.email(),
+                secretProject.get(),
+                null,
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + secretProject.get()
+                        + "' for ref 'refs/*'")),
+            // Test 8
             TestCase.projectRefPerm(
                 privilegedUser.email(),
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.VIEW_PRIVATE_CHANGES,
-                200),
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'privilegedUser' can perform 'viewPrivateChanges' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/master'")),
+            // Test 9
             TestCase.projectRefPerm(
                 privilegedUser.email(),
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.FORGE_SERVER,
-                200));
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'privilegedUser' can perform 'forgeServerAsCommitter' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/master'")));
 
     for (TestCase tc : inputs) {
       String in = newGson().toJson(tc.input);
@@ -273,6 +372,14 @@
         default:
           assertWithMessage(String.format("unknown code %d", want)).fail();
       }
+
+      if (!info.debugLogs.equals(tc.expectedDebugLogs)) {
+        assertWithMessage(
+                String.format(
+                    "check.access(%s, %s) = %s, want %s",
+                    tc.project, in, info.debugLogs, tc.expectedDebugLogs))
+            .fail();
+      }
     }
   }
 
@@ -290,4 +397,34 @@
     assertThat(info.status).isEqualTo(200);
     assertThat(info.message).contains("no branches");
   }
+
+  @Test
+  @Sandboxed
+  public void noRules() throws Exception {
+    normalProject = projectOperations.newProject().create();
+
+    for (AccessSection section :
+        projectOperations.project(allProjectsName).getProjectConfig().getAccessSections()) {
+      if (!section.getName().startsWith(Constants.R_REFS)) {
+        continue;
+      }
+      for (Permission permission : section.getPermissions()) {
+        projectOperations
+            .project(allProjectsName)
+            .forUpdate()
+            .remove(permissionKey(permission.getName()).ref(section.getName()).build())
+            .update();
+      }
+    }
+    AccessCheckInput input = new AccessCheckInput();
+    input.account = privilegedUser.email();
+    input.permission = Permission.READ;
+    input.ref = "refs/heads/main";
+
+    AccessCheckInfo info = gApi.projects().name(normalProject.get()).checkAccess(input);
+    assertThat(info.status).isEqualTo(403);
+
+    assertThat(info.debugLogs).isNotEmpty();
+    assertThat(info.debugLogs.get(0)).contains("Found no rules");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index 3b5ac78..18e192d 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -19,6 +19,7 @@
 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.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
@@ -41,6 +42,7 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.inject.Inject;
 import java.util.Iterator;
 import java.util.List;
@@ -382,6 +384,41 @@
   }
 
   @Test
+  public void cherryPickCommitWithChangeIdToClosedChange() throws Exception {
+    String destBranch = "refs/heads/foo";
+    createBranch(BranchNameKey.create(project, destBranch));
+
+    PushOneCommit.Result r = createChange("refs/for/" + destBranch);
+    ChangeInfo existingDestChange = info(r.getChangeId());
+    String commitToCherryPick = createChange().getCommit().getName();
+
+    gApi.changes().id(existingDestChange.changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(existingDestChange.changeId).current().submit();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch;
+    input.message =
+        String.format("it goes to foo branch\n\nChange-Id: %s\n", existingDestChange.changeId);
+    input.allowConflicts = true;
+    input.allowEmpty = true;
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).commit(commitToCherryPick).cherryPick(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Cherry-pick with Change-Id %s could not update the existing change %d in "
+                    + "destination branch %s of project %s, because the change was closed (MERGED)",
+                existingDestChange.changeId,
+                existingDestChange._number,
+                destBranch,
+                project.get()));
+  }
+
+  @Test
   public void cherryPickCommitWithSetTopic() throws Exception {
     String branch = "foo";
     RevCommit revCommit = createChange().getCommit();
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 2847f64..0f51095 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -115,7 +116,7 @@
         extensionRegistry.newRegistration().add(projectIndexedCounter)) {
       String name = name("foo");
       assertThat(gApi.projects().create(name).get().name).isEqualTo(name);
-
+      assertHead(name, "refs/heads/master");
       RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
       eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
 
@@ -125,6 +126,23 @@
   }
 
   @Test
+  @GerritConfig(name = "gerrit.defaultBranch", value = "main")
+  public void createProject_WhenDefaultBranchIsSetInConfig() throws Exception {
+    ProjectIndexedCounter projectIndexedCounter = new ProjectIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectIndexedCounter)) {
+      String name = name("foo");
+      assertThat(gApi.projects().create(name).get().name).isEqualTo(name);
+      assertHead(name, "refs/heads/main");
+      RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
+      eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
+
+      eventRecorder.assertRefUpdatedEvents(name, "refs/heads/main", new String[] {});
+      projectIndexedCounter.assertReindexOf(name);
+    }
+  }
+
+  @Test
   public void createProjectWithInitialBranches() throws Exception {
     ProjectIndexedCounter projectIndexedCounter = new ProjectIndexedCounter();
     try (Registration registration =
@@ -134,12 +152,13 @@
       ProjectInput input = new ProjectInput();
       input.name = name;
       input.createEmptyCommit = true;
-      input.branches = ImmutableList.of("master", "foo");
+      input.branches = ImmutableList.of("foo", "master");
       assertThat(gApi.projects().create(input).get().name).isEqualTo(name);
       assertThat(
               gApi.projects().name(name).branches().get().stream().map(b -> b.ref).collect(toSet()))
           .containsExactly("refs/heads/foo", "refs/heads/master", "HEAD", RefNames.REFS_CONFIG);
 
+      assertHead(name, "refs/heads/foo");
       RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
       eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
 
@@ -882,7 +901,7 @@
     cfg.fromText(projectOperations.project(allProjects).getConfig().toText());
     cfg.setStringList(
         "label",
-        "Code-Review",
+        LabelId.CODE_REVIEW,
         "value",
         ImmutableList.of("+1 LGTM", "1 LGTM", "0 No Value", "-1 Looks Bad"));
 
@@ -909,7 +928,7 @@
             cfg ->
                 cfg.setStringList(
                     "label",
-                    "Code-Review",
+                    LabelId.CODE_REVIEW,
                     "value",
                     ImmutableList.of("+1 LGTM", "1 LGTM", "0 No Value", "-1 Looks Bad")))
         .invalidate();
@@ -917,8 +936,8 @@
     // Verify that project info can be retrieved and that the label value "+1 LGTM" appears only
     // once.
     ProjectInfo projectInfo = gApi.projects().name(allProjects.get()).get();
-    assertThat(projectInfo.labels.keySet()).containsExactly("Code-Review");
-    assertThat(projectInfo.labels.get("Code-Review").values)
+    assertThat(projectInfo.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(projectInfo.labels.get(LabelId.CODE_REVIEW).values)
         .containsExactly("+1", "LGTM", " 0", "No Value", "-1", "Looks Bad");
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/BUILD b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
index 3bfe2f0..517b041 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
@@ -4,4 +4,12 @@
     srcs = [f],
     group = f[:f.index(".")],
     labels = ["api"],
+    deps = [":revision-diff-it"],
 ) for f in glob(["*IT.java"])]
+
+# This is needed because RevisionDiffIT has subclasses that depend on it
+java_library(
+    name = "revision-diff-it",
+    srcs = ["RevisionDiffIT.java"],
+    deps = ["//java/com/google/gerrit/acceptance:lib"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
index d3d8457..7197425 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
@@ -20,6 +20,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThatList;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
 
 import com.google.common.collect.ImmutableList;
@@ -37,6 +38,7 @@
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
@@ -496,6 +498,25 @@
   }
 
   @Test
+  public void anonymousUsersGetAuthExceptionForPortedDrafts() throws Exception {
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchsetId = changeOps.change(changeId).currentPatchset().get().patchsetId();
+
+    requestScopeOps.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.changes()
+                    .id(patchsetId.changeId().get())
+                    .revision(patchsetId.get())
+                    .portedDrafts());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("requires authentication; only authenticated users can have drafts");
+  }
+
+  @Test
   public void portedDraftCommentHasNoAuthor() throws Exception {
     // Set up change and patchsets.
     Account.Id authorId = accountOps.newAccount().create();
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 2976d78..021a719 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -30,6 +30,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 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.PushOneCommit.Result;
@@ -40,9 +42,11 @@
 import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.FileInfo;
+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.testing.ConfigSuite;
+import com.google.gerrit.extensions.webui.FileWebLink;
+import com.google.inject.Inject;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -55,7 +59,6 @@
 import java.util.function.Function;
 import java.util.stream.IntStream;
 import javax.imageio.ImageIO;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -66,7 +69,8 @@
 public class RevisionDiffIT extends AbstractDaemonTest {
   // @RunWith(Parameterized.class) can't be used as AbstractDaemonTest is annotated with another
   // runner. Using different configs is a workaround to achieve the same.
-  private static final String TEST_PARAMETER_MARKER = "test_only_parameter";
+  protected static final String TEST_PARAMETER_MARKER = "test_only_parameter";
+
   private static final String CURRENT = "current";
   private static final String FILE_NAME = "some_file.txt";
   private static final String FILE_NAME2 = "another_file.txt";
@@ -76,18 +80,16 @@
           .collect(joining());
   private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
 
+  @Inject private ExtensionRegistry extensionRegistry;
+
   private boolean intraline;
+  private boolean useNewDiffCacheListFiles;
+  private boolean useNewDiffCacheGetDiff;
+
   private ObjectId commit1;
   private String changeId;
   private String initialPatchSetId;
 
-  @ConfigSuite.Config
-  public static Config intralineConfig() {
-    Config config = new Config();
-    config.setBoolean(TEST_PARAMETER_MARKER, null, "intraline", true);
-    return config;
-  }
-
   @Before
   public void setUp() throws Exception {
     // Reduce flakiness of tests. (If tests aren't fast enough, we would use a fall-back
@@ -96,6 +98,10 @@
     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");
     commit1 =
@@ -118,6 +124,12 @@
     assertDiffForNewFile(result, COMMIT_MSG, result.getCommit().getFullMessage());
   }
 
+  @Ignore
+  @Test
+  public void diffWithRootCommit() throws Exception {
+    // TODO(ghareeb): Implement this test
+  }
+
   @Test
   public void patchsetLevelFileDiffIsEmpty() throws Exception {
     PushOneCommit.Result result = createChange();
@@ -137,6 +149,26 @@
   }
 
   @Test
+  public void gitwebFileWebLinkIncludedInDiff() throws Exception {
+    try (Registration registration = newGitwebFileWebLink()) {
+      String fileName = "foo.txt";
+      String fileContent = "bar\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.metaB.webLinks).hasSize(1);
+      assertThat(info.metaB.webLinks.get(0).url)
+          .isEqualTo(
+              String.format(
+                  "http://gitweb/?p=%s;hb=%s;f=%s", project, result.getCommit().name(), fileName));
+    }
+  }
+
+  @Test
   public void deletedFileIsIncludedInDiff() throws Exception {
     gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
     gApi.changes().id(changeId).edit().publish();
@@ -446,6 +478,57 @@
   }
 
   @Test
+  public void diffWithThreeParentsMergeCommitChange() throws Exception {
+    // Create a merge commit of 3 files: foo, bar, baz. The merge commit is pointing to 3 different
+    // parents: the merge commit contains foo of parent1, bar of parent2 and baz of parent3.
+    PushOneCommit.Result r =
+        createNParentsMergeCommitChange("refs/for/master", ImmutableList.of("foo", "bar", "baz"));
+
+    DiffInfo diff;
+
+    // parent 1
+    Map<String, FileInfo> changedFiles = gApi.changes().id(r.getChangeId()).current().files(1);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST, "bar", "baz");
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(1).get();
+    assertThat(diff.diffHeader).isNull();
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(1).get();
+    assertThat(diff.diffHeader).hasSize(4);
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "baz").withParent(1).get();
+    assertThat(diff.diffHeader).hasSize(4);
+
+    // parent 2
+    changedFiles = gApi.changes().id(r.getChangeId()).current().files(2);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST, "foo", "baz");
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(2).get();
+    assertThat(diff.diffHeader).hasSize(4);
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(2).get();
+    assertThat(diff.diffHeader).isNull();
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "baz").withParent(2).get();
+    assertThat(diff.diffHeader).hasSize(4);
+
+    // parent 3
+    changedFiles = gApi.changes().id(r.getChangeId()).current().files(3);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST, "foo", "bar");
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(3).get();
+    assertThat(diff.diffHeader).hasSize(4);
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(3).get();
+    assertThat(diff.diffHeader).hasSize(4);
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "baz").withParent(3).get();
+    assertThat(diff.diffHeader).isNull();
+  }
+
+  @Test
+  public void diffWithThreeParentsMergeCommitAgainstAutoMergeReturnsCommitMsgAndMergeListOnly()
+      throws Exception {
+    PushOneCommit.Result r =
+        createNParentsMergeCommitChange("refs/for/master", ImmutableList.of("foo", "bar", "baz"));
+
+    // Diff against auto-merge returns COMMIT_MSG and MERGE_LIST only
+    Map<String, FileInfo> changedFiles = gApi.changes().id(r.getChangeId()).current().files();
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST);
+  }
+
+  @Test
   public void diffBetweenPatchSetsOfMergeCommitCanBeRetrievedForCommitMessageAndMergeList()
       throws Exception {
     PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
@@ -464,6 +547,32 @@
   }
 
   @Test
+  public void diffAgainstAutoMergeCanBeRetrievedForCommitMessageAndMergeList() throws Exception {
+    PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
+    String changeId = result.getChangeId();
+    addModifiedPatchSet(changeId, "my_file.txt", content -> content.concat("Line I\nLine II\n"));
+
+    DiffInfo commitMessageDiffInfo =
+        getDiffRequest(changeId, CURRENT, COMMIT_MSG)
+            .get(); // diff latest PS against base (auto-merge)
+    DiffInfo mergeListDiffInfo =
+        getDiffRequest(changeId, CURRENT, MERGE_LIST)
+            .get(); // diff latest PS against base (auto-merge)
+
+    assertThat(commitMessageDiffInfo).changeType().isEqualTo(ChangeType.ADDED);
+    assertThat(commitMessageDiffInfo).content().hasSize(1);
+
+    assertThat(mergeListDiffInfo).changeType().isEqualTo(ChangeType.ADDED);
+    assertThat(mergeListDiffInfo).content().hasSize(1);
+    assertThat(mergeListDiffInfo)
+        .content()
+        .element(0)
+        .linesOfB()
+        .element(0)
+        .isEqualTo("Merge List:");
+  }
+
+  @Test
   public void diffOfUnmodifiedFileMarksAllLinesAsCommon() throws Exception {
     String filePath = "a_new_file.txt";
     String fileContent = "Line 1\nLine 2\nLine 3\n";
@@ -874,6 +983,37 @@
   }
 
   @Test
+  public void intralineEditsAreIdentified() throws Exception {
+    // TODO(ghareeb): This test asserts the wrong behavior due to the following issue
+    // bugs.chromium.org/p/gerrit/issues/detail?id=13563
+    // Please remove this comment and assert the correct behavior when the bug is fixed.
+
+    assume().that(intraline).isTrue();
+
+    String orig = "[-9999,9999]";
+    String replace = "[-999,999]";
+
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat(orig));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace(orig, replace));
+
+    // TODO(ghareeb): remove this comment when the issue is fixed.
+    // The returned diff incorrectly contains:
+    // replace [-9999{,99}99] with [-999{,}999].
+    // If this replace edit is done, the resulting string incorrectly becomes [-9999,99].
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+
+    List<List<Integer>> editsA = diffInfo.content.get(1).editA;
+    List<List<Integer>> editsB = diffInfo.content.get(1).editB;
+    String reconstructed = transformStringUsingEditList(orig, replace, editsA, editsB);
+
+    // TODO(ghareeb): assert equals when the issue is fixed.
+    assertThat(reconstructed).isNotEqualTo(replace);
+  }
+
+  @Test
   public void intralineEditsForModifiedLastLineArePreservedWhenNewlineIsAlsoAddedAtEnd()
       throws Exception {
     assume().that(intraline).isTrue();
@@ -1189,6 +1329,9 @@
   @Test
   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");
     addModifiedPatchSet(changeId, FILE_NAME2, contentModification);
@@ -1279,6 +1422,9 @@
 
   @Test
   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"));
     addModifiedPatchSet(
@@ -2657,6 +2803,58 @@
   }
 
   @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();
+
+    String target = "file.txt";
+    String symlink = "link.lnk";
+
+    // Create a change adding file "FileName" and a symlink "symLink" pointing to the file
+    PushOneCommit push =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Commit Subject", target, "content")
+            .addSymlink(symlink, target);
+
+    PushOneCommit.Result result = push.to("refs/for/master");
+    String initialRev = gApi.changes().id(result.getChangeId()).get().currentRevision;
+
+    // Delete the symlink with patchset 2
+    gApi.changes().id(result.getChangeId()).edit().deleteFile(symlink);
+    gApi.changes().id(result.getChangeId()).edit().publish();
+
+    // Re-add the symlink as a regular file with patchset 3
+    gApi.changes()
+        .id(result.getChangeId())
+        .edit()
+        .modifyFile(symlink, RawInputUtil.create("Content of the new file named 'symlink'"));
+    gApi.changes().id(result.getChangeId()).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(result.getChangeId()).current().files(initialRev);
+
+    assertThat(changedFiles.keySet()).containsExactly("/COMMIT_MSG", symlink);
+    assertThat(changedFiles.get(symlink).status).isEqualTo('W'); // Rewrite
+
+    DiffInfo diffInfo =
+        gApi.changes().id(result.getChangeId()).current().file(symlink).diff(initialRev);
+
+    // The diff logic identifies two entries for the file:
+    // 1. One entry as 'DELETED' for the symlink.
+    // 2. Another entry as 'ADDED' for the new regular file.
+    // Since the diff logic returns a single entry, we prioritize returning the 'ADDED' entry in
+    // this case so that the client is able to see the new content that was added to the file.
+    assertThat(diffInfo.changeType).isEqualTo(ChangeType.ADDED);
+    assertThat(diffInfo.content).hasSize(1);
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .linesOfB()
+        .containsExactly("Content of the new file named 'symlink'");
+  }
+
+  @Test
   public void diffOfNonExistentFileIsAnEmptyDiffResult() throws Exception {
     addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
 
@@ -2704,6 +2902,21 @@
     assertThat(e).hasMessageThat().isEqualTo("edit not allowed as base");
   }
 
+  private Registration newGitwebFileWebLink() {
+    FileWebLink fileWebLink =
+        new FileWebLink() {
+          @Override
+          public WebLinkInfo getFileWebLink(
+              String projectName, String revision, String hash, String fileName) {
+            return new WebLinkInfo(
+                "name",
+                "imageURL",
+                String.format("http://gitweb/?p=%s;hb=%s;f=%s", projectName, hash, fileName));
+          }
+        };
+    return extensionRegistry.newRegistration().add(fileWebLink);
+  }
+
   private String updatedCommitMessage() {
     return "An unchanged patchset\n\nChange-Id: " + changeId;
   }
@@ -2843,4 +3056,29 @@
         .diffRequest()
         .withIntraline(intraline);
   }
+
+  /**
+   * This method transforms the {@code orig} input String using the list of replace edits {@code
+   * editsA}, {@code editsB} and the resulting {@code replace} String. This method currently assumes
+   * that all input edits are replace edits, and that the edits are sorted according to their
+   * indices.
+   *
+   * @return The transformed String after applying the list of replace edits to the original String.
+   */
+  private String transformStringUsingEditList(
+      String orig, String replace, List<List<Integer>> editsA, List<List<Integer>> editsB) {
+    assertThat(editsA).hasSize(editsB.size());
+    StringBuilder process = new StringBuilder(orig);
+    // The edits are processed right to left to avoid recomputation of indices when characters
+    // are removed.
+    for (int i = editsA.size() - 1; i >= 0; i--) {
+      List<Integer> leftEdit = editsA.get(i);
+      List<Integer> rightEdit = editsB.get(i);
+      process.replace(
+          leftEdit.get(0),
+          leftEdit.get(0) + leftEdit.get(1),
+          replace.substring(rightEdit.get(0), rightEdit.get(0) + rightEdit.get(1)));
+    }
+    return process.toString();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIntralineIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIntralineIT.java
new file mode 100644
index 0000000..ff4ac8e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIntralineIT.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
+
+/** Runs the {@link RevisionDiffIT} tests with the intraline config enabled. */
+public class RevisionDiffIntralineIT extends RevisionDiffIT {
+  @ConfigSuite.Default
+  public static Config intralineConfig() {
+    Config config = new Config();
+    config.setBoolean(TEST_PARAMETER_MARKER, null, "intraline", true);
+    return config;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 2f9530c..abfd7896 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -54,6 +54,7 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.BranchOrderSection;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
@@ -87,15 +88,12 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
-import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.restapi.change.GetRevisionActions;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
@@ -121,7 +119,6 @@
 import org.junit.Test;
 
 public class RevisionIT extends AbstractDaemonTest {
-  @Inject private GetRevisionActions getRevisionActions;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
@@ -165,7 +162,7 @@
     String changeId = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.recommend());
 
-    String label = "Code-Review";
+    String label = LabelId.CODE_REVIEW;
     ApprovalInfo approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(1);
     assertThat(approval.postSubmit).isNull();
@@ -177,10 +174,10 @@
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(1);
     assertThat(approval.postSubmit).isNull();
-    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), "Code-Review", 1, 2);
+    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), LabelId.CODE_REVIEW, 1, 2);
 
-    // Repeating the current label is allowed. Does not flip the postSubmit bit
-    // due to deduplication codepath.
+    // Repeating the current label is allowed. Does not flip the postSubmit bit due to
+    // deduplication codepath.
     gApi.changes().id(changeId).current().review(ReviewInput.recommend());
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(1);
@@ -203,7 +200,7 @@
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(2);
     assertThat(approval.postSubmit).isTrue();
-    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), "Code-Review", 2);
+    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), LabelId.CODE_REVIEW, 2);
 
     // Decreasing to previous post-submit vote is still not allowed.
     thrown =
@@ -230,9 +227,9 @@
     revision(r).review(ReviewInput.recommend());
 
     requestScopeOperations.setApiUser(admin.id());
-    gApi.changes().id(changeId).reviewer(user.username()).deleteVote("Code-Review");
+    gApi.changes().id(changeId).reviewer(user.username()).deleteVote(LabelId.CODE_REVIEW);
     Optional<ApprovalInfo> crUser =
-        get(changeId, DETAILED_LABELS).labels.get("Code-Review").all.stream()
+        get(changeId, DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all.stream()
             .filter(a -> a._accountId == user.id().get())
             .findFirst();
     assertThat(crUser).isPresent();
@@ -242,12 +239,13 @@
 
     requestScopeOperations.setApiUser(user.id());
     ReviewInput in = new ReviewInput();
-    in.label("Code-Review", 1);
+    in.label(LabelId.CODE_REVIEW, 1);
     in.message = "Still LGTM";
     revision(r).review(in);
 
     ApprovalInfo cr =
-        gApi.changes().id(changeId).get(DETAILED_LABELS).labels.get("Code-Review").all.stream()
+        gApi.changes().id(changeId).get(DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all
+            .stream()
             .filter(a -> a._accountId == user.id().get())
             .findFirst()
             .get();
@@ -262,7 +260,7 @@
     revision(r).submit();
 
     ReviewInput in = new ReviewInput();
-    in.label("Code-Review", 0);
+    in.label(LabelId.CODE_REVIEW, 0);
 
     ResourceConflictException thrown =
         assertThrows(ResourceConflictException.class, () -> revision(r).review(in));
@@ -285,7 +283,7 @@
         Iterators.getOnlyElement(
             cd.currentApprovals().stream().filter(a -> !a.isLegacySubmit()).iterator());
     assertThat(psa.patchSetId().get()).isEqualTo(2);
-    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo(2);
     assertThat(psa.postSubmit()).isFalse();
   }
@@ -386,8 +384,11 @@
 
     // The cherry-pick honors the ChangeId specified in the input message:
     RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    // New change was created.
+    assertThat(changeInfo._number).isGreaterThan(orig.get()._number);
+    assertThat(changeInfo.changeId).isEqualTo(id);
     assertThat(revInfo).isNotNull();
-    assertThat(revInfo.commit.message).endsWith(id + "\n");
+    assertThat(revInfo.commit.message.trim()).endsWith(id);
   }
 
   @Test
@@ -477,6 +478,9 @@
     assertThat(cherryInfo.messages).hasSize(2);
     Iterator<ChangeMessageInfo> cherryIt = cherryInfo.messages.iterator();
     assertThat(cherryInfo.cherryPickOfChange).isEqualTo(change.get()._number);
+
+    // Existing change was updated.
+    assertThat(cherryInfo._number).isEqualTo(change.get()._number);
     assertThat(cherryInfo.cherryPickOfPatchSet).isEqualTo(1);
     assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 1.");
     assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 2.");
@@ -708,12 +712,13 @@
     in.destination = "foo";
     in.message = r3.getCommit().getFullMessage();
     cherry = gApi.changes().id(t1).current().cherryPick(in);
+    assertThat(cherry.get()._number).isEqualTo(info(t2)._number);
     assertThat(cherry.get().cherryPickOfChange).isEqualTo(orig.get()._number);
     assertThat(cherry.get().cherryPickOfPatchSet).isEqualTo(2);
   }
 
   @Test
-  public void cherryPickToExistingChange() throws Exception {
+  public void cherryPickToAbandonedChange() throws Exception {
     PushOneCommit.Result r1 =
         pushFactory
             .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "a")
@@ -734,15 +739,17 @@
     CherryPickInput in = new CherryPickInput();
     in.destination = "foo";
     in.message = r1.getCommit().getFullMessage();
-    ResourceConflictException thrown =
+    BadRequestException thrown =
         assertThrows(
-            ResourceConflictException.class, () -> gApi.changes().id(t1).current().cherryPick(in));
+            BadRequestException.class, () -> gApi.changes().id(t1).current().cherryPick(in));
     assertThat(thrown)
         .hasMessageThat()
         .isEqualTo(
-            "Cannot create new patch set of change "
-                + info(t2)._number
-                + " because it is abandoned");
+            String.format(
+                "Cherry-pick with Change-Id %s could not update the existing change %d in "
+                    + "destination branch refs/heads/foo of project %s, because "
+                    + "the change was closed (ABANDONED)",
+                r1.getChangeId(), info(t2)._number, project.get()));
 
     gApi.changes().id(t2).restore();
     gApi.changes().id(t1).current().cherryPick(in);
@@ -751,6 +758,44 @@
   }
 
   @Test
+  public void cherryPickToExistingMergedChange() throws Exception {
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "a")
+            .to("refs/for/master");
+
+    BranchInput bin = new BranchInput();
+    bin.revision = r1.getCommit().getParent(0).name();
+    gApi.projects().name(project.get()).branch("foo").create(bin);
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "b", r1.getChangeId())
+            .to("refs/for/foo");
+    String t2 = project.get() + "~foo~" + r2.getChangeId();
+
+    gApi.changes().id(t2).current().review(ReviewInput.approve());
+    gApi.changes().id(t2).current().submit();
+
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = r1.getCommit().getFullMessage();
+    in.allowConflicts = true;
+    in.allowEmpty = true;
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(t2).current().cherryPick(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Cherry-pick with Change-Id %s could not update the existing change %d in "
+                    + "destination branch refs/heads/foo of project %s, because "
+                    + "the change was closed (MERGED)",
+                r1.getChangeId(), info(t2)._number, project.get()));
+  }
+
+  @Test
   public void cherryPickMergeRelativeToDefaultParent() throws Exception {
     String parent1FileName = "a.txt";
     String parent2FileName = "b.txt";
@@ -872,7 +917,7 @@
     String changeId = project.get() + "~master~" + result.getChangeId();
 
     // 'user' cherry-picks the change to a new branch, the source change's author/committer('admin')
-    // will be added as a reviewer of the newly created change.
+    // will be added as cc of the newly created change.
     requestScopeOperations.setApiUser(user.id());
     CherryPickInput input = new CherryPickInput();
     input.message = "it goes to a new branch";
@@ -882,7 +927,7 @@
     input.notify = NotifyHandling.ALL;
     sender.clear();
     gApi.changes().id(changeId).current().cherryPick(input);
-    assertNotifyTo(admin);
+    assertNotifyCc(admin);
 
     // Disable the notification. 'admin' as a reviewer should not be notified any more.
     input.destination = "branch-2";
@@ -1573,7 +1618,8 @@
     PatchSetWebLink link =
         new PatchSetWebLink() {
           @Override
-          public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
+          public WebLinkInfo getPatchSetWebLink(
+              String projectName, String commit, String commitMessage, String branchName) {
             return expectedWebLinkInfo;
           }
         };
@@ -1778,23 +1824,6 @@
   }
 
   @Test
-  public void actionsETag() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-
-    String oldETag = checkETag(getRevisionActions, r2, null);
-    current(r2).review(ReviewInput.approve());
-    oldETag = checkETag(getRevisionActions, r2, oldETag);
-
-    // Dependent change is included in ETag.
-    current(r1).review(ReviewInput.approve());
-    oldETag = checkETag(getRevisionActions, r2, oldETag);
-
-    current(r2).submit();
-    checkETag(getRevisionActions, r2, oldETag);
-  }
-
-  @Test
   public void deleteVoteOnNonCurrentPatchSet() throws Exception {
     PushOneCommit.Result r = createChange(); // patch set 1
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
@@ -1816,7 +1845,7 @@
                     .id(r.getChangeId())
                     .revision(r.getCommit().getName())
                     .reviewer(user.id().toString())
-                    .deleteVote("Code-Review"));
+                    .deleteVote(LabelId.CODE_REVIEW));
     assertThat(thrown).hasMessageThat().contains("Cannot access on non-current patch set");
   }
 
@@ -1837,12 +1866,12 @@
         .id(r.getChangeId())
         .current()
         .reviewer(user.id().toString())
-        .deleteVote("Code-Review");
+        .deleteVote(LabelId.CODE_REVIEW);
 
     Map<String, Short> m =
         gApi.changes().id(r.getChangeId()).current().reviewer(user.id().toString()).votes();
 
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
+    assertThat(m).containsExactly(LabelId.CODE_REVIEW, Short.valueOf((short) 0));
 
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     ChangeMessageInfo message = Iterables.getLast(c.messages);
@@ -1860,8 +1889,8 @@
     assertThat(votes).isEmpty();
     recommend(changeId);
     votes = gApi.changes().id(changeId).current().votes();
-    assertThat(votes.keySet()).containsExactly("Code-Review");
-    List<ApprovalInfo> approvals = votes.get("Code-Review");
+    assertThat(votes.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    List<ApprovalInfo> approvals = votes.get(LabelId.CODE_REVIEW);
     assertThat(approvals).hasSize(1);
     ApprovalInfo approval = approvals.get(0);
     assertThat(approval._accountId).isEqualTo(admin.id().get());
@@ -1875,8 +1904,8 @@
     // Patch set 1 has 2 votes on Code-Review
     requestScopeOperations.setApiUser(admin.id());
     votes = gApi.changes().id(changeId).current().votes();
-    assertThat(votes.keySet()).containsExactly("Code-Review");
-    approvals = votes.get("Code-Review");
+    assertThat(votes.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    approvals = votes.get(LabelId.CODE_REVIEW);
     assertThat(approvals).hasSize(2);
     assertThat(approvals.stream().map(a -> a._accountId))
         .containsExactlyElementsIn(ImmutableList.of(admin.id().get(), user.id().get()));
@@ -1888,8 +1917,8 @@
 
     // Votes are still returned for ps 1
     votes = gApi.changes().id(changeId).revision(1).votes();
-    assertThat(votes.keySet()).containsExactly("Code-Review");
-    approvals = votes.get("Code-Review");
+    assertThat(votes.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    approvals = votes.get(LabelId.CODE_REVIEW);
     assertThat(approvals).hasSize(2);
   }
 
@@ -1965,13 +1994,6 @@
     return gApi.changes().id(r.getChangeId()).current();
   }
 
-  private String checkETag(ETagView<RevisionResource> view, PushOneCommit.Result r, String oldETag)
-      throws Exception {
-    String eTag = view.getETag(parseRevisionResource(r));
-    assertThat(eTag).isNotEqualTo(oldETag);
-    return eTag;
-  }
-
   private PushOneCommit.Result createCherryPickableMerge(
       String parent1FileName, String parent2FileName) throws Exception {
     RevCommit initialCommit = getHead(repo(), "HEAD");
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheForSingleFileIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheForSingleFileIT.java
new file mode 100644
index 0000000..46954e98
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheForSingleFileIT.java
@@ -0,0 +1,32 @@
+// 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
new file mode 100644
index 0000000..ec0bcc6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java
@@ -0,0 +1,32 @@
+// 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/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 5808ea4..8233f0c 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
@@ -804,7 +805,7 @@
 
   @Test
   public void editCommitMessageCopiesLabelScores() throws Exception {
-    String cr = "Code-Review";
+    String cr = LabelId.CODE_REVIEW;
     try (ProjectConfigUpdate u = updateProject(project)) {
       LabelType codeReview = TestLabels.codeReview();
       u.getConfig().upsertLabelType(codeReview);
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java b/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
index 35f8270..a22759f 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.FakeGroupAuditService;
 import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.pgm.http.jetty.JettyServer;
 import com.google.gerrit.server.audit.HttpAuditEvent;
@@ -27,7 +28,6 @@
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.CredentialsProvider;
 import org.eclipse.jgit.transport.RefSpec;
@@ -48,6 +48,10 @@
     // Don't clear audit events here, since we can't guarantee all test setup has run yet.
   }
 
+  /**
+   * As of today only fetch Protocol V2 is supported on the git client.
+   * https://git.eclipse.org/r/c/jgit/jgit/+/172595
+   */
   @Test
   @Sandboxed
   public void receivePackAuditEventLog() throws Exception {
@@ -77,11 +81,7 @@
   }
 
   @Test
-  public void anonymousUploadPackAuditEventLog() throws Exception {
-    uploadPackAuditEventLog(Constants.DEFAULT_REMOTE_NAME, Optional.empty());
-  }
-
-  @Test
+  @TestProjectInput(createEmptyCommit = false)
   public void authenticatedUploadPackAuditEventLog() throws Exception {
     String remote = "authenticated";
     Config cfg = testRepo.git().getRepository().getConfig();
@@ -93,34 +93,72 @@
     uploadPackAuditEventLog(remote, Optional.of(admin.id()));
   }
 
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void anonymousUploadPackAuditEventLog() throws Exception {
+    String remote = "anonymous";
+    Config cfg = testRepo.git().getRepository().getConfig();
+
+    String uri = server.getUrl() + "/" + project.get();
+    cfg.setString("remote", remote, "url", uri);
+    cfg.setString("remote", remote, "fetch", "+refs/heads/*:refs/remotes/origin/*");
+
+    uploadPackAuditEventLog(remote, Optional.empty());
+  }
+
+  /**
+   * Git client use Protocol V2 fetch by default, see https://git.eclipse.org/r/c/jgit/jgit/+/172595
+   * See {@code org.eclipse.jgit.transport.BasePackFetchConnection#doFetchV2} for the negotiation
+   * details.
+   */
   private void uploadPackAuditEventLog(String remote, Optional<Account.Id> accountId)
       throws Exception {
+    // Make a server-side change to have a common base.
+    createCommit("foo");
+    testRepo.git().fetch().call();
+
+    // Make a server-side change so we have something to fetch.
+    createCommit("bar");
+
     auditService.drainHttpAuditEvents();
-    // testRepo is already a clone. Make a server-side change so we have something to fetch.
-    try (Repository repo = repoManager.openRepository(project);
-        TestRepository<?> testRepo = new TestRepository<>(repo)) {
-      testRepo.branch("master").commit().create();
-    }
     testRepo.git().fetch().setRemote(remote).call();
 
     ImmutableList<HttpAuditEvent> auditEvents = auditService.drainHttpAuditEvents();
-    assertThat(auditEvents).hasSize(2);
+    assertThat(auditEvents).hasSize(3);
 
-    HttpAuditEvent lsRemote = auditEvents.get(0);
-    assertThat(lsRemote.who.toString())
+    // Protocol V2 Capability advertisement
+    // https://git-scm.com/docs/protocol-v2#_capability_advertisement
+    HttpAuditEvent infoRef = auditEvents.get(0);
+
+    assertThat(infoRef.who.toString())
         .isEqualTo(
             accountId.map(id -> "IdentifiedUser[account " + id.get() + "]").orElse("ANONYMOUS"));
-    assertThat(lsRemote.what).endsWith("/info/refs?service=git-upload-pack");
-    assertThat(lsRemote.params).containsExactly("service", "git-upload-pack");
-    assertThat(lsRemote.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
+    assertThat(infoRef.what).endsWith("/info/refs?service=git-upload-pack");
+    assertThat(infoRef.params).containsExactly("service", "git-upload-pack");
+    assertThat(infoRef.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
 
-    HttpAuditEvent uploadPack = auditEvents.get(1);
-    assertThat(uploadPack.who.toString())
-        .isEqualTo(
-            accountId.map(id -> "IdentifiedUser[account " + id.get() + "]").orElse("ANONYMOUS"));
-    assertThat(uploadPack.what).endsWith("/git-upload-pack");
-    assertThat(uploadPack.params).isEmpty();
-    assertThat(uploadPack.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
+    // Smart service negotiations, as described here
+    // https://git-scm.com/docs/http-protocol#_smart_service_git_upload_pack
+    // Protocol V2 client sends command=ls-ref https://git-scm.com/docs/protocol-v2#_ls_refs
+    // followed by command=fetch, thus the request may overflow see
+    // org.eclipse.jgit.transport.MultiRequestService
+    HttpAuditEvent uploadPackLsRef = auditEvents.get(1);
+
+    assertThat(uploadPackLsRef.what).endsWith("/git-upload-pack");
+    assertThat(uploadPackLsRef.params).isEmpty();
+    assertThat(uploadPackLsRef.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
+    HttpAuditEvent uploadPackFetch = auditEvents.get(2);
+
+    assertThat(uploadPackFetch.what).endsWith("/git-upload-pack");
+    assertThat(uploadPackFetch.params).isEmpty();
+    assertThat(uploadPackFetch.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
     assertThat(jettyServer.numActiveSessions()).isEqualTo(0);
   }
+
+  private void createCommit(String message) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      tr.branch("master").commit().message(message).create();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index bb1a2eb..eac0f1b 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -62,12 +62,14 @@
 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.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
@@ -87,16 +89,19 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.receive.NoteDbPushOption;
+import com.google.gerrit.server.git.receive.PluginPushOption;
 import com.google.gerrit.server.git.receive.ReceiveConstants;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
@@ -487,26 +492,34 @@
 
   @Test
   public void pushForMasterWithTopic() throws Exception {
-    String topic = "my/topic";
-    // specify topic as option
-    PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic);
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, topic);
+    TopicValidator topicValidator = new TopicValidator();
+    try (Registration registration = extensionRegistry.newRegistration().add(topicValidator)) {
+      String topic = "my/topic";
+      // specify topic as option
+      PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic);
+      r.assertOkStatus();
+      r.assertChange(Change.Status.NEW, topic);
+      assertThat(topicValidator.count()).isEqualTo(1);
+    }
   }
 
   @Test
   public void pushForMasterWithTopicOption() throws Exception {
-    String topicOption = "topic=myTopic";
-    List<String> pushOptions = new ArrayList<>();
-    pushOptions.add(topicOption);
+    TopicValidator topicValidator = new TopicValidator();
+    try (Registration registration = extensionRegistry.newRegistration().add(topicValidator)) {
+      String topicOption = "topic=myTopic";
+      List<String> pushOptions = new ArrayList<>();
+      pushOptions.add(topicOption);
 
-    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
-    push.setPushOptions(pushOptions);
-    PushOneCommit.Result r = push.to("refs/for/master");
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      push.setPushOptions(pushOptions);
+      PushOneCommit.Result r = push.to("refs/for/master");
 
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, "myTopic");
-    r.assertPushOptions(pushOptions);
+      r.assertOkStatus();
+      r.assertChange(Change.Status.NEW, "myTopic");
+      r.assertPushOptions(pushOptions);
+      assertThat(topicValidator.count()).isEqualTo(1);
+    }
   }
 
   @Test
@@ -1040,7 +1053,7 @@
     PushOneCommit.Result r = pushTo("refs/for/master%l=Code-Review");
     r.assertOkStatus();
     ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
-    LabelInfo cr = ci.labels.get("Code-Review");
+    LabelInfo cr = ci.labels.get(LabelId.CODE_REVIEW);
     assertThat(cr.all).hasSize(1);
     assertThat(cr.all.get(0).name).isEqualTo("Administrator");
     assertThat(cr.all.get(0).value).isEqualTo(1);
@@ -1058,7 +1071,7 @@
     r = push.to("refs/for/master%l=Code-Review+2");
 
     ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
-    cr = ci.labels.get("Code-Review");
+    cr = ci.labels.get(LabelId.CODE_REVIEW);
     assertThat(Iterables.getLast(ci.messages).message)
         .isEqualTo("Uploaded patch set 2: Code-Review+2.");
     // Check that the user who pushed the change was added as a reviewer since they added a vote
@@ -1097,7 +1110,7 @@
     r = push.to("refs/for/master%l=Code-Review+2");
 
     ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
-    LabelInfo cr = ci.labels.get("Code-Review");
+    LabelInfo cr = ci.labels.get(LabelId.CODE_REVIEW);
     assertThat(Iterables.getLast(ci.messages).message)
         .isEqualTo("Uploaded patch set 2: Code-Review+2.");
 
@@ -1126,7 +1139,7 @@
 
     String changeId = GitUtil.getChangeId(testRepo, c).get();
     assertThat(getOwnerEmail(changeId)).isEqualTo(admin.email());
-    assertThat(getReviewerEmails(changeId, ReviewerState.REVIEWER))
+    assertThat(getReviewerEmails(changeId, ReviewerState.CC))
         .containsExactly(user.email(), user2.email());
 
     assertThat(sender.getMessages()).hasSize(1);
@@ -1151,7 +1164,7 @@
     pushHead(testRepo, "refs/for/master");
 
     assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(admin.email());
-    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER))
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.CC))
         .containsExactly(user.email(), user2.email());
 
     assertThat(sender.getMessages()).hasSize(1);
@@ -1183,27 +1196,20 @@
     // Push this commit as "Administrator" (requires Forge Committer Identity)
     pushHead(testRepo, "refs/for/master%l=Code-Review+1", false);
 
-    // Expected Code-Review votes:
-    // 1. 0 from User (committer):
-    //    When the committer is forged, the committer is automatically added as
-    //    reviewer, hence we expect a dummy 0 vote for the committer.
-    // 2. +1 from Administrator (uploader):
-    //    On push Code-Review+1 was specified, hence we expect a +1 vote from
-    //    the uploader.
+    // Expected Code-Review vote:
+    // +1 from Administrator (uploader):
+    // On push Code-Review+1 was specified, hence we expect a +1 vote from the uploader. When the
+    // committer is forged, the committer is automatically added as cc, but that doesn't add votes
+    // (as opposted to being added as reviewer that adds a dummy +0 vote). We ensure there are no
+    // votes from the committer.
     ChangeInfo ci =
         get(GitUtil.getChangeId(testRepo, c).get(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
     LabelInfo cr = ci.labels.get("Code-Review");
-    assertThat(cr.all).hasSize(2);
-    int indexAdmin = admin.fullName().equals(cr.all.get(0).name) ? 0 : 1;
-    int indexUser = indexAdmin == 0 ? 1 : 0;
-    assertThat(cr.all.get(indexAdmin).name).isEqualTo(admin.fullName());
-    assertThat(cr.all.get(indexAdmin).value.intValue()).isEqualTo(1);
-    assertThat(cr.all.get(indexUser).name).isEqualTo(user.fullName());
-    assertThat(cr.all.get(indexUser).value.intValue()).isEqualTo(0);
+    ApprovalInfo approvalInfo = Iterables.getOnlyElement(cr.all);
+    assertThat(approvalInfo.name).isEqualTo(admin.fullName());
+    assertThat(approvalInfo.value.intValue()).isEqualTo(1);
     assertThat(Iterables.getLast(ci.messages).message)
         .isEqualTo("Uploaded patch set 1: Code-Review+1.");
-    // Check that the user who pushed the change was added as a reviewer since they added a vote
-    assertThatUserIsOnlyReviewer(ci, admin);
   }
 
   @Test
@@ -2360,6 +2366,8 @@
     private final AtomicInteger count = new AtomicInteger();
     private final boolean validateAll;
 
+    @Nullable private CommitReceivedEvent receivedEvent;
+
     TestValidator(boolean validateAll) {
       this.validateAll = validateAll;
     }
@@ -2369,7 +2377,8 @@
     }
 
     @Override
-    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) {
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receivedEvent) {
+      this.receivedEvent = receivedEvent;
       count.incrementAndGet();
       return Collections.emptyList();
     }
@@ -2382,6 +2391,44 @@
     public int count() {
       return count.get();
     }
+
+    @Nullable
+    public CommitReceivedEvent getReceivedEvent() {
+      return receivedEvent;
+    }
+  }
+
+  private static class TestPluginPushOption implements PluginPushOption {
+    private final String name;
+    private final String description;
+
+    TestPluginPushOption(String name, String description) {
+      this.name = name;
+      this.description = description;
+    }
+
+    @Override
+    public String getName() {
+      return name;
+    }
+
+    @Override
+    public String getDescription() {
+      return description;
+    }
+  }
+
+  private static class TopicValidator implements TopicEditedListener {
+    private final AtomicInteger count = new AtomicInteger();
+
+    @Override
+    public void onTopicEdited(Event event) {
+      count.incrementAndGet();
+    }
+
+    public int count() {
+      return count.get();
+    }
   }
 
   @Test
@@ -2432,6 +2479,38 @@
   }
 
   @Test
+  public void pushOptionsArePassedToCommitValidationListener() throws Exception {
+    TestValidator validator = new TestValidator();
+    PluginPushOption fooOption = new TestPluginPushOption("foo", "some description");
+    PluginPushOption barOption = new TestPluginPushOption("bar", "other description");
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(validator).add(fooOption).add(barOption)) {
+      PushOneCommit push =
+          pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
+      push.setPushOptions(ImmutableList.of("trace=123", "gerrit~foo", "gerrit~bar=456"));
+      PushOneCommit.Result r = push.to("refs/for/master");
+      r.assertOkStatus();
+      assertThat(validator.getReceivedEvent().pushOptions)
+          .containsExactly("trace", "123", "gerrit~foo", "", "gerrit~bar", "456");
+    }
+  }
+
+  @Test
+  public void pluginPushOptionsHelp() throws Exception {
+    PluginPushOption fooOption = new TestPluginPushOption("foo", "some description");
+    PluginPushOption barOption = new TestPluginPushOption("bar", "other description");
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(fooOption).add(barOption)) {
+      PushOneCommit push =
+          pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
+      push.setPushOptions(ImmutableList.of("help"));
+      PushOneCommit.Result r = push.to("refs/for/master");
+      r.assertErrorStatus("see help");
+      r.assertMessage("-o gerrit~bar: other description\n-o gerrit~foo: some description\n");
+    }
+  }
+
+  @Test
   public void pushNoteDbRef() throws Exception {
     String ref = "refs/changes/34/1234/meta";
     RevCommit c = testRepo.commit().message("Junk NoteDb commit").create();
@@ -2728,6 +2807,22 @@
     assertPushOk(r, "refs/for/otherBranch");
   }
 
+  @Test
+  public void pushWithVoteDoesNotAddToAttentionSet() throws Exception {
+    String pushSpec = "refs/for/master%l=Code-Review+1";
+    PushOneCommit.Result r = pushTo(pushSpec);
+    r.assertOkStatus();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void pushForMasterWithUnknownOption() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("unknown=foo"));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("\"--unknown\" is not a valid option");
+  }
+
   private DraftInput newDraft(String path, int line, String message) {
     DraftInput d = new DraftInput();
     d.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java b/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
new file mode 100644
index 0000000..46688dd
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
@@ -0,0 +1,189 @@
+// 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.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.MergeInput;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Ensures that auto merge commits are created when a new patch set or change is uploaded. */
+public class AutoMergeIT extends AbstractDaemonTest {
+  private RevCommit parent1;
+  private RevCommit parent2;
+
+  @Before
+  public void setup() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    PushOneCommit.Result p1 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "parent 1",
+                ImmutableMap.of("foo", "foo-1.2", "bar", "bar-1.2"))
+            .to("refs/for/master");
+    parent1 = p1.getCommit();
+
+    // reset HEAD in order to create a sibling of the first change
+    testRepo.reset(initial);
+
+    PushOneCommit.Result p2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "parent 2",
+                ImmutableMap.of("foo", "foo-2.2", "bar", "bar-2.2"))
+            .to("refs/for/master");
+    parent2 = p2.getCommit();
+  }
+
+  @Test
+  public void autoMergeCreatedWhenPushingNewChange() 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());
+  }
+
+  @Test
+  public void autoMergeCreatedWhenPushingNewPatchSet() 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 ps1 = m.to("refs/for/master");
+    RevCommit ps2 =
+        testRepo
+            .amend(ps1.getCommit())
+            .message("PS2")
+            .insertChangeId(ps1.getChangeId().substring(1))
+            .create();
+    testRepo.reset(ps2);
+    GitUtil.pushHead(testRepo, "refs/for/master");
+    // Make sure we have two patch sets
+    assertThat(ps2.getParents().length).isEqualTo(2);
+    assertThat(gApi.changes().id(ps1.getChangeId()).get().revisions.size()).isEqualTo(2);
+    assertAutoMergeCreated(ps2);
+  }
+
+  @Test
+  public void autoMergeCreatedWhenChangeCreatedOnApi() throws Exception {
+    ChangeInput ci = new ChangeInput(project.get(), "master", "Merge commit");
+    ci.merge = new MergeInput();
+    ci.merge.source = parent1.name();
+
+    String newChangePatchSetSha1 = gApi.changes().create(ci).get().currentRevision;
+    assertAutoMergeCreated(ObjectId.fromString(newChangePatchSetSha1));
+  }
+
+  @Test
+  public void autoMergeCreatedWhenNewPatchSetCreatedOnApi() throws Exception {
+    ChangeInput ci = new ChangeInput(project.get(), "master", "Merge commit");
+    ci.merge = new MergeInput();
+    ci.merge.source = parent1.name();
+
+    String changeId = gApi.changes().create(ci).get().changeId;
+    gApi.changes().id(changeId).setMessage("New Commit Message\n\nChange-Id: " + changeId);
+    assertThat(gApi.changes().id(changeId).get().revisions.size()).isEqualTo(2);
+    String newChangePatchSetSha1 = gApi.changes().id(changeId).get().currentRevision;
+    assertAutoMergeCreated(ObjectId.fromString(newChangePatchSetSha1));
+  }
+
+  @Test
+  public void autoMergeCreatedWhenChangeEditIsPublished() 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());
+
+    gApi.changes()
+        .id(result.getChangeId())
+        .edit()
+        .modifyFile("new-file", RawInputUtil.create("content"));
+    gApi.changes().id(result.getChangeId()).edit().publish();
+    assertThat(gApi.changes().id(result.getChangeId()).get().revisions.size()).isEqualTo(2);
+    String newChangePatchSetSha1 = gApi.changes().id(result.getChangeId()).get().currentRevision;
+    assertAutoMergeCreated(ObjectId.fromString(newChangePatchSetSha1));
+  }
+
+  @Test
+  public void noAutoMergeCreatedWhenPushingNonMergeCommit() throws Exception {
+    PushOneCommit.Result change = createChange();
+    change.assertOkStatus();
+    assertNoAutoMergeCreated(change.getCommit());
+  }
+
+  @Test
+  public void autoMergeComputedInMemoryWhenMissing() 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 auto merge branch
+    deleteAutoMergeBranch(result.getCommit());
+    // Trigger AutoMerge computation
+    assertThat(gApi.changes().id(result.getChangeId()).revision(1).file("foo").blameRequest().get())
+        .isNotEmpty();
+    assertNoAutoMergeCreated(result.getCommit());
+  }
+
+  private void assertAutoMergeCreated(ObjectId mergeCommit) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef(RefNames.refsCacheAutomerge(mergeCommit.name()))).isNotNull();
+    }
+  }
+
+  private void assertNoAutoMergeCreated(ObjectId mergeCommit) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef(RefNames.refsCacheAutomerge(mergeCommit.name()))).isNull();
+    }
+  }
+
+  private void deleteAutoMergeBranch(ObjectId mergeCommit) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      RefUpdate ru = repo.updateRef(RefNames.refsCacheAutomerge(mergeCommit.name()));
+      ru.setForceUpdate(true);
+      assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+    assertNoAutoMergeCreated(mergeCommit);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/BUILD b/javatests/com/google/gerrit/acceptance/git/BUILD
index ef54c92..13311e3 100644
--- a/javatests/com/google/gerrit/acceptance/git/BUILD
+++ b/javatests/com/google/gerrit/acceptance/git/BUILD
@@ -21,7 +21,6 @@
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
         "//java/com/google/gerrit/git",
-        "//java/com/google/gerrit/mail",
     ],
 )
 
diff --git a/javatests/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java b/javatests/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java
deleted file mode 100644
index 6b7adf1..0000000
--- a/javatests/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java
+++ /dev/null
@@ -1,96 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.git;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.acceptance.FakeGroupAuditService;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.audit.HttpAuditEvent;
-import com.google.inject.Inject;
-import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.CredentialsProvider;
-import org.eclipse.jgit.transport.RefSpec;
-import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
-import org.junit.Before;
-import org.junit.Test;
-
-public class HttpPushForReviewIT extends AbstractPushForReview {
-  @Inject private FakeGroupAuditService auditService;
-
-  @Before
-  public void selectHttpUrl() throws Exception {
-    CredentialsProvider.setDefault(
-        new UsernamePasswordCredentialsProvider(admin.username(), admin.httpPassword()));
-    selectProtocol(Protocol.HTTP);
-    // Don't clear audit events here, since we can't guarantee all test setup has run yet.
-  }
-
-  @Test
-  public void receivePackAuditEventLog() throws Exception {
-    auditService.drainHttpAuditEvents();
-    testRepo
-        .git()
-        .push()
-        .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/for/master"))
-        .call();
-
-    ImmutableList<HttpAuditEvent> auditEvents = auditService.drainHttpAuditEvents();
-    assertThat(auditEvents).hasSize(2);
-
-    HttpAuditEvent lsRemote = auditEvents.get(0);
-    assertThat(lsRemote.who.getAccountId()).isEqualTo(admin.id());
-    assertThat(lsRemote.what).endsWith("/info/refs?service=git-receive-pack");
-    assertThat(lsRemote.params).containsExactly("service", "git-receive-pack");
-    assertThat(lsRemote.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
-
-    HttpAuditEvent receivePack = auditEvents.get(1);
-    assertThat(receivePack.who.getAccountId()).isEqualTo(admin.id());
-    assertThat(receivePack.what).endsWith("/git-receive-pack");
-    assertThat(receivePack.params).isEmpty();
-    assertThat(receivePack.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
-  }
-
-  @Test
-  public void uploadPackAuditEventLog() throws Exception {
-    auditService.drainHttpAuditEvents();
-    // testRepo is already a clone. Make a server-side change so we have something to fetch.
-    try (Repository repo = repoManager.openRepository(project);
-        TestRepository<Repository> tr = new TestRepository<>(repo)) {
-      tr.branch("master").commit().create();
-    }
-    testRepo.git().fetch().call();
-
-    ImmutableList<HttpAuditEvent> auditEvents = auditService.drainHttpAuditEvents();
-    assertThat(auditEvents).hasSize(2);
-
-    HttpAuditEvent lsRemote = auditEvents.get(0);
-    // Repo URL doesn't include /a, so fetching doesn't cause authentication.
-    assertThat(lsRemote.who).isInstanceOf(AnonymousUser.class);
-    assertThat(lsRemote.what).endsWith("/info/refs?service=git-upload-pack");
-    assertThat(lsRemote.params).containsExactly("service", "git-upload-pack");
-    assertThat(lsRemote.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
-
-    HttpAuditEvent uploadPack = auditEvents.get(1);
-    assertThat(lsRemote.who).isInstanceOf(AnonymousUser.class);
-    assertThat(uploadPack.what).endsWith("/git-upload-pack");
-    assertThat(uploadPack.params).isEmpty();
-    assertThat(uploadPack.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 1a2ae7c..385780b 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -82,17 +82,14 @@
         .update();
   }
 
-  @SuppressWarnings("TruthIncompatibleType")
   @Test
   public void mixingMagicAndRegularPush() throws Exception {
     testRepo.branch("HEAD").commit().create();
     PushResult r = push("HEAD:refs/heads/master", "HEAD:refs/for/master");
 
     String msg = "cannot combine normal pushes and magic pushes";
-    assertThat(r.getRemoteUpdate("refs/heads/master"))
-        .isNotEqualTo(/* expected: RemoteRefUpdate, actual: Status */ Status.OK);
-    assertThat(r.getRemoteUpdate("refs/for/master"))
-        .isNotEqualTo(/* expected: RemoteRefUpdate, actual: Status */ Status.OK);
+    assertThat(r.getRemoteUpdate("refs/heads/master").getStatus()).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/for/master").getStatus()).isNotEqualTo(Status.OK);
     assertThat(r.getRemoteUpdate("refs/for/master").getMessage()).isEqualTo(msg);
   }
 
@@ -364,7 +361,7 @@
         .update();
 
     String project2 = name("project2");
-    gApi.projects().create(project2);
+    projectOperations.newProject().name(project2).create();
 
     ObjectId oldId = forceFetch("refs/meta/config");
 
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 0930815..b7acbe2 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.receive.ReceiveCommitsAdvertiseRefsHookChain;
 import com.google.gerrit.server.git.receive.testing.TestRefAdvertiser;
@@ -118,7 +119,7 @@
   @Before
   public void setUp() throws Exception {
     admins = adminGroupUuid();
-    nonInteractiveUsers = groupUuid("Service Users");
+    nonInteractiveUsers = groupUuid(ServiceUserClassifier.SERVICE_USERS);
     setUpPermissions();
     setUpChanges();
   }
@@ -402,6 +403,39 @@
   }
 
   @Test
+  public void uploadPackSubsetOfBranchesVisibleAllPatchsets() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result pushResult =
+        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master");
+    pushResult.assertOkStatus();
+    String firstPatchSetRef = RefNames.patchSetRef(pushResult.getPatchSetId());
+
+    pushResult = amendChange(pushResult.getChangeId());
+    pushResult.assertOkStatus();
+
+    String secondPatchSetRef = RefNames.patchSetRef(pushResult.getPatchSetId());
+
+    assertUploadPackRefs(
+        "HEAD",
+        psRef1,
+        metaRef1,
+        psRef3,
+        metaRef3,
+        RefNames.changeMetaRef(pushResult.getChange().getId()),
+        firstPatchSetRef,
+        // include all patchsets of the visible changes
+        secondPatchSetRef,
+        "refs/heads/master",
+        "refs/tags/master-tag");
+    // tree-tag not visible. See comment in subsetOfBranchesVisibleIncludingHead.
+  }
+
+  @Test
   public void uploadPackSubsetOfBranchesAndEditsVisibleWithViewPrivateChanges() throws Exception {
     projectOperations
         .project(project)
diff --git a/javatests/com/google/gerrit/acceptance/pgm/BUILD b/javatests/com/google/gerrit/acceptance/pgm/BUILD
index 64bd25c..5b18d02 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/BUILD
+++ b/javatests/com/google/gerrit/acceptance/pgm/BUILD
@@ -27,7 +27,6 @@
     labels = [
         "docker",
         "elastic",
-        "exclusive",
         "pgm",
         "no_windows",
     ],
diff --git a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
index 23d7658..093711f 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.schema.NoteDbSchemaVersion;
 import com.google.gerrit.server.schema.Schema_184;
 import com.google.gerrit.testing.TestUpdateUI;
@@ -26,7 +27,8 @@
 import org.junit.Test;
 
 public class Schema_184IT extends AbstractDaemonTest {
-  private static final AccountGroup.NameKey SERVICE_USERS = AccountGroup.nameKey("Service Users");
+  private static final AccountGroup.NameKey SERVICE_USERS =
+      AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS);
   private static final AccountGroup.NameKey NON_INTERACTIVE_USERS =
       AccountGroup.nameKey("Non-Interactive Users");
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index f5d9e3a..02916c7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -18,6 +18,13 @@
 import static org.apache.http.HttpStatus.SC_CREATED;
 import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
 import static org.apache.http.HttpStatus.SC_OK;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
@@ -36,7 +43,6 @@
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.httpd.restapi.ParameterParser;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
-import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.validators.CommitValidationException;
@@ -52,11 +58,10 @@
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
-import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
 import org.apache.http.message.BasicHeader;
 import org.junit.Rule;
 import org.junit.Test;
@@ -337,7 +342,7 @@
     assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
     assertForceLogging(false);
     try (TraceContext traceContext = TraceContext.open().forceLogging().addTag("foo", "bar")) {
-      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      Map<String, ? extends Set<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
       assertThat(tagMap.keySet()).containsExactly("foo");
       assertThat(tagMap.get("foo")).containsExactly("bar");
       assertForceLogging(true);
@@ -348,7 +353,7 @@
               () -> {
                 // Verify that the tags and force logging flag have been propagated to the new
                 // thread.
-                SortedMap<String, SortedSet<Object>> threadTagMap =
+                Map<String, ? extends Set<Object>> threadTagMap =
                     LoggingContext.getInstance().getTags().asMap();
                 expect.that(threadTagMap.keySet()).containsExactly("foo");
                 expect.that(threadTagMap.get("foo")).containsExactly("bar");
@@ -370,37 +375,31 @@
 
   @Test
   public void performanceLoggingForRestCall() throws Exception {
-    TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
+    PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
         extensionRegistry.newRegistration().add(testPerformanceLogger)) {
       RestResponse response = adminRestSession.put("/projects/new10");
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-
-      // This assertion assumes that the server invokes the PerformanceLogger plugins before it
-      // sends
-      // the response to the client. If this assertion gets flaky it's likely that this got changed
-      // on
-      // server-side.
-      assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+      verify(testPerformanceLogger, timeout(5000).atLeastOnce()).log(anyString(), anyLong(), any());
     }
   }
 
   @Test
   public void performanceLoggingForPush() throws Exception {
-    TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
+    PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
         extensionRegistry.newRegistration().add(testPerformanceLogger)) {
       PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
       PushOneCommit.Result r = push.to("refs/heads/master");
       r.assertOkStatus();
-      assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+      verify(testPerformanceLogger, timeout(5000).atLeastOnce()).log(anyString(), anyLong(), any());
     }
   }
 
   @Test
   @GerritConfig(name = "tracing.performanceLogging", value = "false")
   public void noPerformanceLoggingIfDisabled() throws Exception {
-    TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
+    PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
         extensionRegistry.newRegistration().add(testPerformanceLogger)) {
       RestResponse response = adminRestSession.put("/projects/new11");
@@ -410,7 +409,7 @@
       PushOneCommit.Result r = push.to("refs/heads/master");
       r.assertOkStatus();
 
-      assertThat(testPerformanceLogger.logEntries()).isEmpty();
+      verifyZeroInteractions(testPerformanceLogger);
     }
   }
 
@@ -713,7 +712,7 @@
 
   @Test
   @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
-  public void autoRetryWithTrace() throws Exception {
+  public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
     String changeId = createChange().getChangeId();
     approve(changeId);
 
@@ -723,49 +722,6 @@
       RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
       assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).startsWith("retry-on-failure-");
-      assertThat(traceSubmitRule.traceId).startsWith("retry-on-failure-");
-      assertThat(traceSubmitRule.isLoggingForced).isTrue();
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
-  public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-
-    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
-    traceSubmitRule.failAlways = true;
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(traceSubmitRule)
-            .add(
-                new ExceptionHook() {
-                  @Override
-                  public boolean shouldRetry(String actionType, String actionName, Throwable t) {
-                    return true;
-                  }
-                })) {
-      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
-      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
-      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
-      assertThat(traceSubmitRule.traceId).isNull();
-      assertThat(traceSubmitRule.isLoggingForced).isFalse();
-    }
-  }
-
-  @Test
-  public void noAutoRetryWithTraceIfDisabled() throws Exception {
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-
-    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
-    traceSubmitRule.failOnce = true;
-    try (Registration registration = extensionRegistry.newRegistration().add(traceSubmitRule)) {
-      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
-      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
-      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
       assertThat(traceSubmitRule.traceId).isNull();
       assertThat(traceSubmitRule.isLoggingForced).isFalse();
     }
@@ -844,19 +800,6 @@
     }
   }
 
-  private static class TestPerformanceLogger implements PerformanceLogger {
-    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
-
-    @Override
-    public void log(String operation, long durationMs, Metadata metadata) {
-      logEntries.add(PerformanceLogEntry.create(operation, metadata));
-    }
-
-    ImmutableList<PerformanceLogEntry> logEntries() {
-      return ImmutableList.copyOf(logEntries);
-    }
-  }
-
   @AutoValue
   abstract static class PerformanceLogEntry {
     static PerformanceLogEntry create(String operation, Metadata metadata) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
index aaeed02..8feac20 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
@@ -44,7 +44,7 @@
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.EnableReverseDnsLookup;
+import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -56,7 +56,7 @@
 public class EmailIT extends AbstractDaemonTest {
   @Inject private @AnonymousCowardName String anonymousCowardName;
   @Inject private @CanonicalWebUrl Provider<String> canonicalUrl;
-  @Inject private @EnableReverseDnsLookup boolean enableReverseDnsLookup;
+  @Inject private @EnablePeerIPInReflogRecord boolean enablePeerIPInReflogRecord;
   @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
   @Inject private AuthConfig authConfig;
   @Inject private EmailExpander emailExpander;
@@ -280,7 +280,7 @@
             realm,
             anonymousCowardName,
             canonicalUrl,
-            enableReverseDnsLookup,
+            enablePeerIPInReflogRecord,
             accountCache,
             groupBackend);
     return atrScope.set(atrScope.newContext(null, userFactory.create(admin.id())));
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index a5cf3e1..b999abd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.inject.Inject;
 import org.junit.Test;
 
@@ -45,7 +46,11 @@
   public void getDetailForServiceUser() throws Exception {
     Account.Id serviceUser = accountOperations.newAccount().create();
     groupOperations
-        .group(groupCache.get(AccountGroup.nameKey("Service Users")).get().getGroupUUID())
+        .group(
+            groupCache
+                .get(AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS))
+                .get()
+                .getGroupUUID())
         .forUpdate()
         .addMember(serviceUser)
         .update();
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
index 2e2f5d9..1e61d0a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.inject.Inject;
 import org.junit.Test;
 
@@ -70,7 +71,8 @@
   @Test
   public void getServiceUserAccount() throws Exception {
     TestAccount serviceUser =
-        accountCreator.create("robot1", "robot1@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot1", "robot1@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     assertThat(serviceUser.tags()).containsExactly("SERVICE_USER");
     testGetAccount(serviceUser.id().toString(), serviceUser);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index 5c596dc..bf8de93 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -183,7 +184,7 @@
 
     ReviewInput in = new ReviewInput();
     in.onBehalfOf = user.id().toString();
-    in.label("Verified", 1);
+    in.label(LabelId.VERIFIED, 1);
 
     AuthException thrown = assertThrows(AuthException.class, () -> revision.review(in));
     assertThat(thrown)
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
index 2c9107c..b70cab8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
@@ -249,4 +249,30 @@
   public void postWithoutBody() throws Exception {
     adminRestSession.post("/accounts/" + admin.username() + "/watched.projects").assertOK();
   }
+
+  @Test
+  public void nullProjectThrowsBadRequestException() {
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = null;
+    projectsToWatch.add(pwi);
+    Throwable t =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+    assertThat(t.getMessage()).isEqualTo("project name must be specified");
+  }
+
+  @Test
+  public void emptyProjectThrowsBadRequestException() {
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = "  ";
+    projectsToWatch.add(pwi);
+    Throwable t =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+    assertThat(t.getMessage()).isEqualTo("project name must be specified");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java b/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
index 9298b43..00b1c55 100644
--- a/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.acceptance.rest.auth;
 
-import static com.google.common.truth.Truth.assertThat;
-
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.RestSession;
@@ -34,6 +32,5 @@
     RestSession anonymous = new RestSession(server, null);
     RestResponse r = anonymous.get("/auth-check");
     r.assertForbidden();
-    assertThat(r.getHeader("Content-Length")).isEqualTo("0");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
index b447534..16dc294 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
@@ -16,6 +16,7 @@
 
 import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -48,6 +49,7 @@
 import javax.servlet.http.HttpServletRequestWrapper;
 import javax.servlet.http.HttpServletResponse;
 import org.junit.Test;
+import org.kohsuke.args4j.Option;
 
 /**
  * Tests for checking plugin-provided REST API bindings directly under {@code /}.
@@ -192,8 +194,15 @@
 
   @Singleton
   static class TestGet implements RestReadView<TestPluginResource> {
+
+    @Option(name = "--crash")
+    String crash;
+
     @Override
     public Response<String> apply(TestPluginResource resource) throws Exception {
+      if (!Strings.nullToEmpty(crash).isEmpty()) {
+        throw new IllegalStateException();
+      }
       return Response.ok("test");
     }
   }
@@ -204,4 +213,13 @@
       RestApiCallHelper.execute(adminRestSession, TEST_CALLS.asList());
     }
   }
+
+  @Test
+  public void testOptionOnSingletonIsIgnored() throws Exception {
+    try (AutoCloseable ignored = installPlugin(PLUGIN_NAME, null, MyPluginHttpModule.class, null)) {
+      RestApiCallHelper.execute(
+          adminRestSession,
+          RestCall.get("/plugins/" + PLUGIN_NAME + "/test-collection/1/detail?crash=xyz"));
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index faef5aa..796ce38 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -59,6 +59,7 @@
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Permission;
@@ -228,7 +229,9 @@
                   "Cannot rebase "
                       + change2hash
                       + ": The change could "
-                      + "not be rebased due to a conflict during merge.");
+                      + "not be rebased due to a conflict during merge.\n\n"
+                      + "merge conflict(s):\n"
+                      + "a.txt");
           break;
         case MERGE_ALWAYS:
         case MERGE_IF_NECESSARY:
@@ -413,7 +416,7 @@
         .forUpdate()
         .add(block(Permission.SUBMIT).ref("refs/*").group(CHANGE_OWNER))
         .add(allow(Permission.SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
-        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
         .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
@@ -439,7 +442,7 @@
         .forUpdate()
         .add(block(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(CHANGE_OWNER))
-        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
         .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
@@ -1375,7 +1378,6 @@
     ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info.messages).isNotNull();
     Iterable<String> messages = Iterables.transform(info.messages, i -> i.message);
-    assertThat(messages).hasSize(3);
     String last = Iterables.getLast(messages);
     if (getSubmitType() == SubmitType.CHERRY_PICK) {
       assertThat(last).startsWith("Change has been successfully cherry-picked as");
@@ -1386,6 +1388,17 @@
     }
   }
 
+  @Test
+  public void submitSetsMergedOn() throws Throwable {
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().getMergedOn()).isEmpty();
+    submit(r.getChangeId());
+    assertThat(r.getChange().getMergedOn()).isPresent();
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(r.getChange().getMergedOn().get()).isEqualTo(change.updated);
+    assertThat(r.getChange().getMergedOn().get()).isEqualTo(change.submitted);
+  }
+
   @Override
   protected void updateProjectInput(ProjectInput in) {
     in.submitType = getSubmitType();
@@ -1439,6 +1452,12 @@
     assertWithMessage("enabled bit on submit action").that(desc.isEnabled()).isTrue();
   }
 
+  protected void assertSubmitDisabled(String changeId) throws Throwable {
+    RevisionResource rsrc = parseCurrentRevisionResource(changeId);
+    UiAction.Description desc = submitHandler.getDescription(rsrc);
+    assertWithMessage("enabled bit on submit action").that(desc.isEnabled()).isFalse();
+  }
+
   protected void assertChangeMergedEvents(String... expected) throws Throwable {
     eventRecorder.assertChangeMergedEvents(project.get(), "refs/heads/master", expected);
   }
@@ -1470,7 +1489,7 @@
 
   protected void assertApproved(String changeId, TestAccount user) throws Throwable {
     ChangeInfo c = get(changeId, DETAILED_LABELS);
-    LabelInfo cr = c.labels.get("Code-Review");
+    LabelInfo cr = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(cr.all).hasSize(1);
     assertThat(cr.all.get(0).value).isEqualTo(2);
     assertThat(Account.id(cr.all.get(0)._accountId)).isEqualTo(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index f77552d..9c496fa 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -117,4 +118,49 @@
     assertThat(head.getParent(0)).isEqualTo(change1.getCommit());
     assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
   }
+
+  @Test
+  public void dependencyOnOutdatedPatchSetPreventsMerge() throws Throwable {
+    // Create a change
+    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+    PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+    // Create new patch set for first change.
+    testRepo.reset(changeResult.getCommit().name());
+    amendChange(changeResult.getChangeId());
+
+    // Approve both changes
+    approve(changeResult.getChangeId());
+    approve(change2Result.getChangeId());
+
+    // submit button is disabled.
+    assertSubmitDisabled(change2Result.getChangeId());
+
+    submitWithConflict(
+        change2Result.getChangeId(),
+        "Failed to submit 2 changes due to the following problems:\n"
+            + "Change "
+            + change2Result.getChange().getId()
+            + ": Depends on change that was not submitted."
+            + " Commit "
+            + change2Result.getCommit().name()
+            + " depends on commit "
+            + changeResult.getCommit().name()
+            + ", which is outdated patch set "
+            + patchSetId.get()
+            + " of change "
+            + changeResult.getChange().getId()
+            + ". The latest patch set is "
+            + changeResult.getPatchSetId().get()
+            + ".");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index 955dd7a..81c098f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -235,7 +236,9 @@
         change2.getChangeId(),
         "Cannot rebase "
             + change2.getCommit().name()
-            + ": The change could not be rebased due to a conflict during merge.");
+            + ": The change could not be rebased due to a conflict during merge.\n\n"
+            + "merge conflict(s):\n"
+            + "a.txt");
     RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head).isEqualTo(headAfterFirstSubmit);
     assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
@@ -362,7 +365,9 @@
         "Cannot rebase "
             + change2.getCommit().getName()
             + ": "
-            + "The change could not be rebased due to a conflict during merge.");
+            + "The change could not be rebased due to a conflict during merge.\n\n"
+            + "merge conflict(s):\n"
+            + "fileName 2");
     assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterChange1);
   }
 
@@ -384,4 +389,49 @@
     gApi.changes().id(change2.getChangeId()).current().rebase();
     submit(change2.getChangeId());
   }
+
+  @Test
+  public void dependencyOnOutdatedPatchSetPreventsRebase() throws Throwable {
+    // Create a change
+    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+    PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+    // Create new patch set for first change.
+    testRepo.reset(changeResult.getCommit().name());
+    amendChange(changeResult.getChangeId());
+
+    // Approve both changes
+    approve(changeResult.getChangeId());
+    approve(change2Result.getChangeId());
+
+    // submit button is disabled.
+    assertSubmitDisabled(change2Result.getChangeId());
+
+    submitWithConflict(
+        change2Result.getChangeId(),
+        "Failed to submit 2 changes due to the following problems:\n"
+            + "Change "
+            + change2Result.getChange().getId()
+            + ": Depends on change that was not submitted."
+            + " Commit "
+            + change2Result.getCommit().name()
+            + " depends on commit "
+            + changeResult.getCommit().name()
+            + ", which is outdated patch set "
+            + patchSetId.get()
+            + " of change "
+            + changeResult.getChange().getId()
+            + ". The latest patch set is "
+            + changeResult.getPatchSetId().get()
+            + ".");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index c6a2819..e35f758 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -20,26 +20,20 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.server.change.RevisionJson;
-import com.google.gerrit.server.change.testing.TestChangeETagComputation;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
@@ -56,7 +50,6 @@
     return submitWholeTopicEnabledConfig();
   }
 
-  @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private RevisionJson.Factory revisionJsonFactory;
   @Inject private ExtensionRegistry extensionRegistry;
 
@@ -68,10 +61,6 @@
     return gApi.changes().id(id).get().actions;
   }
 
-  protected String getETag(String id) throws Exception {
-    return gApi.changes().id(id).current().etag();
-  }
-
   @Test
   public void changeActionOneMergedChangeHasOnlyNormalRevert() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
@@ -138,124 +127,6 @@
   }
 
   @Test
-  public void revisionActionsETag() throws Exception {
-    String parent = createChange().getChangeId();
-    String change = createChangeWithTopic().getChangeId();
-    approve(change);
-    String etag1 = getETag(change);
-
-    approve(parent);
-    String etag2 = getETag(change);
-
-    String changeWithSameTopic = createChangeWithTopic().getChangeId();
-    String etag3 = getETag(change);
-
-    approve(changeWithSameTopic);
-    String etag4 = getETag(change);
-
-    if (isSubmitWholeTopicEnabled()) {
-      assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
-    } else {
-      assertThat(etag2).isNotEqualTo(etag1);
-      assertThat(etag3).isEqualTo(etag2);
-      assertThat(etag4).isEqualTo(etag2);
-    }
-  }
-
-  @Test
-  public void revisionActionsAnonymousETag() throws Exception {
-    String parent = createChange().getChangeId();
-    String change = createChangeWithTopic().getChangeId();
-    approve(change);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag1 = getETag(change);
-
-    requestScopeOperations.setApiUser(admin.id());
-    approve(parent);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag2 = getETag(change);
-
-    requestScopeOperations.setApiUser(admin.id());
-    String changeWithSameTopic = createChangeWithTopic().getChangeId();
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag3 = getETag(change);
-
-    requestScopeOperations.setApiUser(admin.id());
-    approve(changeWithSameTopic);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag4 = getETag(change);
-
-    if (isSubmitWholeTopicEnabled()) {
-      assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
-    } else {
-      assertThat(etag2).isNotEqualTo(etag1);
-      assertThat(etag3).isEqualTo(etag2);
-      assertThat(etag4).isEqualTo(etag2);
-    }
-  }
-
-  @Test
-  @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
-  public void revisionActionsAnonymousETagCherryPickStrategy() throws Exception {
-    String parent = createChange().getChangeId();
-    String change = createChange().getChangeId();
-    approve(change);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag1 = getETag(change);
-
-    requestScopeOperations.setApiUser(admin.id());
-    approve(parent);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag2 = getETag(change);
-    assertThat(etag2).isEqualTo(etag1);
-  }
-
-  @Test
-  public void pluginCanContributeToETagComputation() throws Exception {
-    String change = createChange().getChangeId();
-    String oldETag = getETag(change);
-
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag("foo"))) {
-      assertThat(getETag(change)).isNotEqualTo(oldETag);
-    }
-
-    assertThat(getETag(change)).isEqualTo(oldETag);
-  }
-
-  @Test
-  public void returningNullFromETagComputationDoesNotBreakGerrit() throws Exception {
-    String change = createChange().getChangeId();
-    String oldETag = getETag(change);
-
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag(null))) {
-      assertThat(getETag(change)).isEqualTo(oldETag);
-    }
-  }
-
-  @Test
-  public void throwingExceptionFromETagComputationDoesNotBreakGerrit() throws Exception {
-    String change = createChange().getChangeId();
-    String oldETag = getETag(change);
-
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                TestChangeETagComputation.withException(
-                    new StorageException("exception during test")))) {
-      assertThat(getETag(change)).isEqualTo(oldETag);
-    }
-  }
-
-  @Test
   public void revisionActionsTwoChangesInTopic_conflicting() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
     approve(changeId);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 5c50949..efd27d0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -47,6 +48,7 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -61,6 +63,7 @@
 import java.util.List;
 import java.util.concurrent.TimeUnit;
 import java.util.function.LongSupplier;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import org.junit.Before;
 import org.junit.Test;
@@ -113,48 +116,48 @@
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
     int accountId =
-        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "first"))._accountId;
-    assertThat(accountId).isEqualTo(user.id().get());
+        change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "first"))._accountId;
+    assertThat(accountId).isEqualTo(admin.id().get());
     AttentionSetUpdate expectedAttentionSetUpdate =
         AttentionSetUpdate.createFromRead(
-            fakeClock.now(), user.id(), AttentionSetUpdate.Operation.ADD, "first");
+            fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.ADD, "first");
     assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
 
     // Second add is ignored.
     accountId =
-        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "second"))._accountId;
-    assertThat(accountId).isEqualTo(user.id().get());
+        change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "second"))._accountId;
+    assertThat(accountId).isEqualTo(admin.id().get());
     assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
 
     // Only one email since the second add was ignored.
     String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
     assertThat(emailBody)
         .contains(
-            user.fullName()
-                + " added themselves to the attention set of this change.\n The reason is: first.");
+            String.format(
+                "%s requires the attention of %s to this change.\n The reason is: first.",
+                user.fullName(), admin.fullName()));
   }
 
   @Test
   public void addMultipleUsers() throws Exception {
     PushOneCommit.Result r = createChange();
     Instant timestamp1 = fakeClock.now();
-    int accountId1 =
-        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"))._accountId;
-    assertThat(accountId1).isEqualTo(user.id().get());
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
     fakeClock.advance(Duration.ofSeconds(42));
     Instant timestamp2 = fakeClock.now();
     int accountId2 =
         change(r)
-            .addToAttentionSet(new AttentionSetInput(admin.id().toString(), "admin"))
+            .addToAttentionSet(new AttentionSetInput(admin.id().toString(), "manual update"))
             ._accountId;
     assertThat(accountId2).isEqualTo(admin.id().get());
 
     AttentionSetUpdate expectedAttentionSetUpdate1 =
         AttentionSetUpdate.createFromRead(
-            timestamp1, user.id(), AttentionSetUpdate.Operation.ADD, "user");
+            timestamp1, user.id(), AttentionSetUpdate.Operation.ADD, "Reviewer was added");
     AttentionSetUpdate expectedAttentionSetUpdate2 =
         AttentionSetUpdate.createFromRead(
-            timestamp2, admin.id(), AttentionSetUpdate.Operation.ADD, "admin");
+            timestamp2, admin.id(), AttentionSetUpdate.Operation.ADD, "manual update");
     assertThat(r.getChange().attentionSet())
         .containsExactly(expectedAttentionSetUpdate1, expectedAttentionSetUpdate2);
   }
@@ -162,7 +165,9 @@
   @Test
   public void removeUser() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "added"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
+    sender.clear();
     requestScopeOperations.setApiUser(user.id());
 
     fakeClock.advance(Duration.ofSeconds(42));
@@ -189,6 +194,9 @@
   @Test
   public void removeUserWithInvalidUserInput() throws Exception {
     PushOneCommit.Result r = createChange();
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
+
     BadRequestException exception =
         assertThrows(
             BadRequestException.class,
@@ -197,7 +205,9 @@
                     .attention(user.id().toString())
                     .remove(new AttentionSetInput("invalid user", "reason")));
     assertThat(exception.getMessage())
-        .isEqualTo("The user specified in the input body couldn't be found.");
+        .isEqualTo(
+            "invalid user doesn't exist or is not active on the change as an owner, "
+                + "uploader, reviewer, or cc so they can't be added to the attention set");
 
     exception =
         assertThrows(
@@ -212,16 +222,10 @@
   }
 
   @Test
-  public void removeUnrelatedUser() throws Exception {
-    PushOneCommit.Result r = createChange();
-    change(r).attention(user.id().toString()).remove(new AttentionSetInput("foo"));
-    assertThat(r.getChange().attentionSet()).isEmpty();
-  }
-
-  @Test
   public void abandonRemovesUsers() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
     change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "admin"));
 
     change(r).abandon();
@@ -242,7 +246,8 @@
   @Test
   public void workInProgressRemovesUsers() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
 
     change(r).setWorkInProgress();
 
@@ -256,13 +261,10 @@
   public void submitRemovesUsersForAllSubmittedChanges() throws Exception {
     PushOneCommit.Result r1 = createChange("refs/heads/master", "file1", "content");
 
-    change(r1)
-        .current()
-        .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r1).current().review(ReviewInput.approve().reviewer(user.email()));
     PushOneCommit.Result r2 = createChange("refs/heads/master", "file2", "content");
-    change(r2)
-        .current()
-        .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+    change(r2).current().review(ReviewInput.approve().reviewer(user.email()));
 
     change(r2).current().submit();
 
@@ -284,21 +286,26 @@
 
   @Test
   public void robotSubmitsRemovesUsers() throws Exception {
-    PushOneCommit.Result r1 = createChange("refs/heads/master", "file1", "content");
+    PushOneCommit.Result r = createChange("refs/heads/master", "file1", "content");
 
-    change(r1)
-        .current()
-        .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
 
     TestAccount robot =
         accountCreator.create(
-            "robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users", "Administrators");
+            "robot2",
+            "robot2@example.com",
+            "Ro Bot",
+            "Ro",
+            ServiceUserClassifier.SERVICE_USERS,
+            "Administrators");
     requestScopeOperations.setApiUser(robot.id());
-    change(r1).current().submit();
+    change(r).current().review(ReviewInput.approve());
+    change(r).current().submit();
 
     // Attention set updates that relate to the admin (the person who replied) are filtered out.
     AttentionSetUpdate attentionSet =
-        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r1, user));
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
 
     assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
     assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
@@ -460,7 +467,12 @@
 
     TestAccount robot =
         accountCreator.create(
-            "robot1", "robot1@example.com", "Ro Bot", "Ro", "Service Users", "Administrators");
+            "robot1",
+            "robot1@example.com",
+            "Ro Bot",
+            "Ro",
+            ServiceUserClassifier.SERVICE_USERS,
+            "Administrators");
     requestScopeOperations.setApiUser(robot.id());
     change(r).setReadyForReview();
     AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
@@ -483,6 +495,62 @@
   }
 
   @Test
+  public void readyForReviewHasNoEffectOnReadyChanges() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r)
+        .current()
+        .review(ReviewInput.create().reviewer(user.email()).blockAutomaticAttentionSetRules());
+
+    change(r).current().review(ReviewInput.create().setWorkInProgress(false));
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    change(r).current().review(ReviewInput.create().setReady(true));
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void workInProgressHasNoEffectOnWorkInProgressChanges() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create()
+                .reviewer(user.email())
+                .setWorkInProgress(true)
+                .addUserToAttentionSet(user.email(), /* reason= */ "reason"));
+
+    change(r).current().review(ReviewInput.create().setWorkInProgress(true));
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+
+    change(r).current().review(ReviewInput.create().setReady(false));
+    attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+  }
+
+  @Test
+  public void rebaseDoesNotAddToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+
+    // create an unrelated change so that we can rebase
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result unrelated = createChange();
+    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+    gApi.changes().id(r.getChangeId()).rebase();
+
+    // rebase has no impact on the attention set
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
   public void readyForReviewWhileRemovingReviewerRemovesThemToAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
     change(r).setWorkInProgress();
@@ -521,7 +589,9 @@
   @Test
   public void reviewersAreNotAddedForNoReasonBecauseOfAnUpdate() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
+
     change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
 
     HashtagsInput hashtagsInput = new HashtagsInput();
@@ -555,7 +625,8 @@
   @Test
   public void reviewRemovesManuallyRemovedUserFromAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
     requestScopeOperations.setApiUser(user.id());
 
     ReviewInput reviewInput =
@@ -575,7 +646,8 @@
   @Test
   public void reviewWithManualAdditionToAttentionSetFailsWithoutReason() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
 
     ReviewInput reviewInput = ReviewInput.create().addUserToAttentionSet(user.email(), "");
 
@@ -599,7 +671,8 @@
   @Test
   public void reviewAddReviewerWhileRemovingFromAttentionSetJustRemovesUser() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "addition"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
 
     ReviewInput reviewInput =
         ReviewInput.create()
@@ -760,7 +833,15 @@
 
     requestScopeOperations.setApiUser(user.id());
 
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    // add the user to the attention set.
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create()
+                .reviewer(user.email(), ReviewerState.CC, true)
+                .addUserToAttentionSet(user.email(), "reason"));
+
+    // add the user as reviewer but still be removed on reply.
     ReviewInput reviewInput = ReviewInput.create().reviewer(user.email());
     change(r).current().review(reviewInput);
 
@@ -1132,6 +1213,9 @@
   @Test
   public void repliesWhileAddingAsReviewerStillRemovesUser() throws Exception {
     PushOneCommit.Result r = createChange();
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
+
     change(r).addToAttentionSet(new AttentionSetInput(user.email(), "remove"));
 
     requestScopeOperations.setApiUser(user.id());
@@ -1222,8 +1306,11 @@
   @Test
   public void robotsNotAddedToAttentionSet() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot1", "robot1@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot1", "robot1@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
+    // Make the robot active on the change.
+    change(r).addReviewer(robot.email());
 
     // Throw an error when adding a robot explicitly.
     BadRequestException exception =
@@ -1242,7 +1329,8 @@
   @Test
   public void robotAddingAReviewerChangeAttentionSet() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(robot.id());
     change(r).addReviewer(user.id().toString());
@@ -1258,7 +1346,8 @@
   @Test
   public void robotReviewDoesNotChangeAttentionSet() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(robot.id());
     change(r).current().review(ReviewInput.recommend());
@@ -1269,7 +1358,8 @@
   @Test
   public void robotReviewWithNegativeLabelAddsOwner() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(robot.id());
     change(r).current().review(ReviewInput.dislike());
@@ -1284,7 +1374,8 @@
   @Test
   public void robotCommentDoesNotAddOwnerOnClosedChanges() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).abandon();
 
@@ -1301,7 +1392,8 @@
   @Test
   public void robotCanChangeAttentionSetExplicitly() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(robot.id());
     change(r).current().review(new ReviewInput().addUserToAttentionSet(admin.email(), "reason"));
@@ -1317,13 +1409,15 @@
   public void addUsersToAttentionSetInPrivateChanges() throws Exception {
     PushOneCommit.Result r = createChange();
     change(r).setPrivate(true);
-    change(r).current().review(new ReviewInput().addUserToAttentionSet(user.email(), "reason"));
+
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
 
     AttentionSetUpdate attentionSet =
         Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
     assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
     assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
-    assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
   }
 
   @Test
@@ -1368,43 +1462,30 @@
   public void attentionSetEmailHeader() throws Exception {
     PushOneCommit.Result r = createChange();
     TestAccount user2 = accountCreator.user2();
+
+    // The pattern ensures the header mentions the attention set requirements in any order.
+    Pattern attentionSetHeaderPattern =
+        Pattern.compile(
+            String.format(
+                "Attention is currently required from: (%s|%s), (%s|%s).",
+                user2.fullName(), user.fullName(), user.fullName(), user2.fullName()));
     // Add user and user2 to the attention set.
     change(r)
         .current()
         .review(
             ReviewInput.create().reviewer(user.email()).reviewer(accountCreator.user2().email()));
     assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
-        .contains(
-            "Attention is currently required from: "
-                + user2.fullName()
-                + ", "
-                + user.fullName()
-                + ".");
+        .containsMatch(attentionSetHeaderPattern);
     assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
-        .contains(
-            "Attention is currently required from: "
-                + user2.fullName()
-                + ", "
-                + user.fullName()
-                + ".");
+        .containsMatch(attentionSetHeaderPattern);
     sender.clear();
 
     // Irrelevant reply, User and User2 are still in the attention set.
     change(r).current().review(ReviewInput.approve());
     assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
-        .contains(
-            "Attention is currently required from: "
-                + user2.fullName()
-                + ", "
-                + user.fullName()
-                + ".");
+        .containsMatch(attentionSetHeaderPattern);
     assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
-        .contains(
-            "Attention is currently required from: "
-                + user2.fullName()
-                + ", "
-                + user.fullName()
-                + ".");
+        .containsMatch(attentionSetHeaderPattern);
     sender.clear();
 
     // Abandon the change which removes user from attention set; there is an email but without the
@@ -1457,6 +1538,58 @@
   }
 
   @Test
+  public void attentionSetWithEmailFilterFiltersNewPatchsets() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add preference for the user such that they only receive an email on changes that require
+    // their attention.
+    requestScopeOperations.setApiUser(user.id());
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+    gApi.accounts().self().setPreferences(prefs);
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Add user to reviewers but not to the attention set
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create()
+                .reviewer(user.email())
+                .removeUserFromAttentionSet(user.email(), "reason"));
+    sender.clear();
+
+    // amending a change doesn't send an email when user is not in the attention set.
+    amendChange(r.getChangeId());
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void attentionSetWithEmailFilterStillReceivesSubmitEmail() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add preference for the user such that they only receive an email on changes that require
+    // their attention.
+    requestScopeOperations.setApiUser(user.id());
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+    gApi.accounts().self().setPreferences(prefs);
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Add user to reviewers but not to the attention set
+    change(r)
+        .current()
+        .review(
+            ReviewInput.approve()
+                .reviewer(user.email())
+                .removeUserFromAttentionSet(user.email(), "reason"));
+    sender.clear();
+
+    // submitting the change sends an email even when user is not in the attention set.
+    change(r).current().submit();
+    assertThat(sender.getMessages()).isNotEmpty();
+  }
+
+  @Test
   public void attentionSetWithEmailFilterImpactingOnlyChangeEmails() throws Exception {
     // Add preference for the user such that they only receive an email on changes that require
     // their attention.
@@ -1468,6 +1601,156 @@
   }
 
   @Test
+  public void cannotAddIrrelevantUserToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            String.format(
+                "%s doesn't exist or is not active on the change as an owner, uploader, reviewer, "
+                    + "or cc so they can't be added to the attention set",
+                user.email()));
+  }
+
+  @Test
+  public void cannotAddNonExistingUserToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> change(r).addToAttentionSet(new AttentionSetInput("INVALID USER", "reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            "INVALID USER doesn't exist or is not active on the change as an owner,"
+                + " uploader, reviewer, or cc so they can't be added to the attention set");
+  }
+
+  @Test
+  public void cannotRemoveIrrelevantUserToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> change(r).attention(user.email()).remove(new AttentionSetInput("reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            String.format(
+                "%s doesn't exist or is not active on the change as an owner, uploader, reviewer, "
+                    + "or cc so they can't be added to the attention set",
+                user.email()));
+  }
+
+  @Test
+  public void cannotRemoveIrrelevantUserToAttentionSetWithUserInInput() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                change(r)
+                    .attention(user.email())
+                    .remove(new AttentionSetInput(user.email(), "reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            String.format(
+                "%s doesn't exist or is not active on the change as an owner, uploader, reviewer, "
+                    + "or cc so they can't be added to the attention set",
+                user.email()));
+  }
+
+  @Test
+  public void cannotRemoveNonExistingUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> change(r).attention("INVALID USER").remove(new AttentionSetInput("reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            "INVALID USER doesn't exist or is not active on the change as an owner,"
+                + " uploader, reviewer, or cc so they can't be added to the attention set");
+  }
+
+  @Test
+  public void irrelevantUsersAddedToAttentionSetAreIgnoredOnReply() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    change(r).current().review(ReviewInput.create().addUserToAttentionSet(user.email(), "reason"));
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
+  public void newReviewerCanBeAddedToTheAttentionSetManually() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create()
+                .reviewer(user.email())
+                .addUserToAttentionSet(user.email(), "reason")
+                .blockAutomaticAttentionSetRules());
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user)).operation())
+        .isEqualTo(Operation.ADD);
+  }
+
+  @Test
+  public void newReviewerCanBeAddedToTheAttentionSetAutomatically() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).current().review(ReviewInput.create().reviewer(user.email()));
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user)).operation())
+        .isEqualTo(Operation.ADD);
+  }
+
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void onReplyCanAddInvisibleUsersToAttentionSetOnVisibleChanges() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+
+    // admin is invisible to the user, but they can still add them to the attention set since they
+    // see the change.
+    change(r).current().review(ReviewInput.create().addUserToAttentionSet(admin.email(), "reason"));
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin)).operation())
+        .isEqualTo(Operation.ADD);
+  }
+
+  @Test
+  public void onReplyNonExistingUsersAreSilentlyIgnored() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    change(r)
+        .current()
+        .review(ReviewInput.create().addUserToAttentionSet("INVALID USER", "reason"));
+    assertThat(getAttentionSetUpdates(r.getChange().getId())).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void canModifyAttentionSetForInvisibleUsersOnVisibleChanges() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+
+    // admin is invisible to the user, but they can still add them to the attention set since they
+    // see the change.
+    change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "reason"));
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin)).operation())
+        .isEqualTo(Operation.ADD);
+
+    // admin is invisible to the user, but they can still remove them to the attention set since
+    // they see the change.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin)).operation())
+        .isEqualTo(Operation.REMOVE);
+  }
+
+  @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 264ced6..ad3a3c1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.extensions.api.changes.DeleteChangeMessageInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -253,7 +254,7 @@
     c.message = "comment 1";
     c.path = FILE_NAME;
 
-    ReviewInput reviewInput = new ReviewInput().label("Code-Review", 1);
+    ReviewInput reviewInput = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
     reviewInput.comments = ImmutableMap.of(c.path, Lists.newArrayList(c));
     reviewInput.message = changeMessage;
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMetaIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMetaIT.java
new file mode 100644
index 0000000..2cd04ed
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMetaIT.java
@@ -0,0 +1,150 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.entities.RefNames.changeMetaRef;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.inject.AbstractModule;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+/** Test handling of the NoteDb commit hash in the GetChange endpoint */
+public class ChangeMetaIT extends AbstractDaemonTest {
+  @Test
+  public void metaSha1_fromIndex() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+
+    try (AutoCloseable ignored = disableNoteDb()) {
+      ChangeInfo change =
+          Iterables.getOnlyElement(gApi.changes().query().withQuery("change:" + changeId).get());
+
+      try (Repository repo = repoManager.openRepository(project)) {
+        assertThat(change.metaRevId)
+            .isEqualTo(
+                repo.exactRef(changeMetaRef(Change.id(change._number))).getObjectId().getName());
+      }
+    }
+  }
+
+  @Test
+  public void metaSha1_fromNoteDb() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    ChangeInfo before = gApi.changes().id(changeId).get();
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(before.metaRevId)
+          .isEqualTo(
+              repo.exactRef(changeMetaRef(Change.id(before._number))).getObjectId().getName());
+    }
+  }
+
+  @Test
+  public void ChangeInfo_metaSha1_parameter() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).setMessage("before\n\n" + "Change-Id: " + result.getChangeId());
+    ChangeInfo before = gApi.changes().id(changeId).get();
+    gApi.changes().id(changeId).setMessage("after\n\n" + "Change-Id: " + result.getChangeId());
+    ChangeInfo after = gApi.changes().id(changeId).get();
+    assertThat(after.metaRevId).isNotEqualTo(before.metaRevId);
+
+    RestResponse resp = adminRestSession.get("/changes/" + changeId + "/?meta=" + before.metaRevId);
+    resp.assertOK();
+
+    ChangeInfo got;
+    try (JsonReader jsonReader = new JsonReader(resp.getReader())) {
+      jsonReader.setLenient(true);
+      got = newGson().fromJson(jsonReader, ChangeInfo.class);
+    }
+    assertThat(got.subject).isEqualTo(before.subject);
+  }
+
+  @Test
+  public void metaUnreachableSha1() throws Exception {
+    PushOneCommit.Result ch1 = createChange();
+    PushOneCommit.Result ch2 = createChange();
+
+    ChangeInfo info2 = gApi.changes().id(ch2.getChangeId()).get();
+
+    RestResponse resp =
+        adminRestSession.get("/changes/" + ch1.getChangeId() + "/?meta=" + info2.metaRevId);
+
+    resp.assertStatus(412);
+  }
+
+  protected static class PluginDefinedSimpleAttributeModule extends AbstractModule {
+    static class MyMetaHash extends PluginDefinedInfo {
+      String myMetaRef;
+    }
+
+    static PluginDefinedInfo newMyMetaHash(ChangeData cd) {
+      MyMetaHash mmh = new MyMetaHash();
+      mmh.myMetaRef = cd.notes().getMetaId().name();
+      return mmh;
+    }
+
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .toInstance(
+              (cds, bp, p) -> {
+                Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+                cds.forEach(cd -> out.put(cd.getId(), newMyMetaHash(cd)));
+                return out;
+              });
+    }
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void pluginDefinedAttribute() throws Exception {
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedSimpleAttributeModule.class)) {
+      PushOneCommit.Result result = createChange();
+      String changeId = result.getChangeId();
+      gApi.changes().id(changeId).setMessage("before\n\n" + "Change-Id: " + result.getChangeId());
+      ChangeInfo before = gApi.changes().id(changeId).get();
+      gApi.changes().id(changeId).setMessage("after\n\n" + "Change-Id: " + result.getChangeId());
+
+      RestResponse resp =
+          adminRestSession.get("/changes/" + changeId + "/?meta=" + before.metaRevId);
+      resp.assertOK();
+
+      Map<String, Object> changeInfo =
+          newGson().fromJson(resp.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
+      List<Object> plugins = (List<Object>) changeInfo.get("plugins");
+      Map<String, Object> myplugin = (Map<String, Object>) plugins.get(0);
+
+      assertThat(myplugin.get("my_meta_ref")).isEqualTo(before.metaRevId);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
index 10194eb..6bffdf7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -146,8 +147,8 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(groupUUID).range(-2, 2))
-        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/heads/*"), exclusive)
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(groupUUID).range(-2, 2))
+        .setExclusiveGroup(labelPermissionKey(LabelId.CODE_REVIEW).ref("refs/heads/*"), exclusive)
         .update();
   }
 
@@ -156,7 +157,7 @@
         .project(project)
         .forUpdate()
         .add(
-            blockLabel("Code-Review")
+            blockLabel(LabelId.CODE_REVIEW)
                 .ref("refs/heads/*")
                 .group(SystemGroupBackend.CHANGE_OWNER)
                 .range(-2, 2))
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index a6bd5eb..f9493c2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -28,6 +28,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
@@ -37,6 +38,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -283,7 +285,7 @@
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     assertReviewers(c, REVIEWER, user);
     assertReviewers(c, CC);
-    LabelInfo label = c.labels.get("Code-Review");
+    LabelInfo label = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(label).isNotNull();
     assertThat(label.all).isNotNull();
     assertThat(label.all).hasSize(1);
@@ -313,7 +315,7 @@
     assertReviewers(c, CC, user);
     // Verify no approvals were added.
     assertThat(c.labels).isNotNull();
-    LabelInfo label = c.labels.get("Code-Review");
+    LabelInfo label = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(label).isNotNull();
     assertThat(label.all).isNull();
   }
@@ -327,7 +329,7 @@
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     assertReviewers(c, REVIEWER);
     assertReviewers(c, CC);
-    LabelInfo label = c.labels.get("Code-Review");
+    LabelInfo label = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(label).isNotNull();
     assertThat(label.all).isNull();
 
@@ -342,7 +344,7 @@
     c = gApi.changes().id(r.getChangeId()).get();
     assertReviewers(c, REVIEWER, user);
     assertReviewers(c, CC);
-    label = c.labels.get("Code-Review");
+    label = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(label).isNotNull();
     assertThat(label.all).isNotNull();
     assertThat(label.all).hasSize(1);
@@ -366,7 +368,7 @@
     c = gApi.changes().id(r.getChangeId()).get();
     assertReviewers(c, REVIEWER, user);
     assertReviewers(c, CC);
-    label = c.labels.get("Code-Review");
+    label = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(label).isNotNull();
     assertThat(label.all).isNotNull();
     assertThat(label.all).hasSize(1);
@@ -600,7 +602,7 @@
 
   @Test
   public void removingReviewerRemovesTheirVote() throws Exception {
-    String crLabel = "Code-Review";
+    String crLabel = LabelId.CODE_REVIEW;
     PushOneCommit.Result r = createChange();
     ReviewInput input = ReviewInput.approve().reviewer(admin.email());
     ReviewResult addResult = review(r.getChangeId(), r.getCommit().name(), input);
@@ -657,13 +659,124 @@
   }
 
   @Test
+  public void removeReviewerWithVoteOnMergedChangeForChangeOwnerFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 1));
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+  }
+
+  @Test
+  public void removeReviewerWithVoteOnMergedChangeForUserFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 1));
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+  }
+
+  @Test
+  public void removeReviewerWithoutVoteOnMergedChangeForChangeOwnerSucceeds() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(
+            Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).get().removableReviewers)
+                .email)
+        .isEqualTo(user.email());
+
+    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+
+    // Admin is a "reviewer" since the admin submitted the change, this ensures user is not a
+    // reviewer.
+    assertThat(
+            Iterables.getOnlyElement(
+                    gApi.changes().id(r.getChangeId()).get().reviewers.get(REVIEWER))
+                .email)
+        .doesNotMatch(user.email());
+  }
+
+  @Test
+  public void removeReviewerWithoutVoteOnMergedChangeForUserSucceeds() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThat(
+            Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).get().removableReviewers)
+                .email)
+        .isEqualTo(user.email());
+
+    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+
+    // Admin is a "reviewer" since the admin submitted the change, this ensures user is not a
+    // reviewer.
+    assertThat(
+            Iterables.getOnlyElement(
+                    gApi.changes().id(r.getChangeId()).get().reviewers.get(REVIEWER))
+                .email)
+        .doesNotMatch(user.email());
+  }
+
+  @Test
   public void removeReviewerWithVoteWithoutPermissionFails() throws Exception {
     PushOneCommit.Result r = createChange();
     TestAccount newUser = createAccounts(1, name("foo")).get(0);
 
     requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Code-Review", 1));
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 1));
     requestScopeOperations.setApiUser(newUser.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
     AuthException thrown =
         assertThrows(
             AuthException.class,
@@ -687,17 +800,53 @@
     gApi.changes().id(r.getChangeId()).addReviewer(user.email());
     assertThatUserIsOnlyReviewer(r.getChangeId());
     requestScopeOperations.setApiUser(newUser.id());
+    assertThat(
+            Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).get().removableReviewers)
+                .email)
+        .isEqualTo(user.email());
+
     gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
     assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
   }
 
   @Test
+  @Sandboxed
+  public void removeReviewerWithoutVoteOnAMergedChangeWithPermissionSucceeds() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // This test creates a new user so that it can explicitly check the REMOVE_REVIEWER permission
+    // rather than bypassing the check because of project or ref ownership.
+    TestAccount newUser = createAccounts(1, name("foo")).get(0);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REMOVE_REVIEWER).ref(RefNames.REFS + "*").group(REGISTERED_USERS))
+        .update();
+
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    requestScopeOperations.setApiUser(newUser.id());
+
+    // Ensures user is removable.
+    assertThat(
+            gApi.changes().id(r.getChangeId()).get().removableReviewers.stream()
+                .filter(a -> user.email().equals(a.email))
+                .findAny()
+                .isPresent())
+        .isTrue();
+    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+  }
+
+  @Test
   public void removeReviewerWithoutVoteWithoutPermissionFails() throws Exception {
     PushOneCommit.Result r = createChange();
     TestAccount newUser = createAccounts(1, name("foo")).get(0);
 
     gApi.changes().id(r.getChangeId()).addReviewer(user.email());
     requestScopeOperations.setApiUser(newUser.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
     AuthException thrown =
         assertThrows(
             AuthException.class,
@@ -715,6 +864,8 @@
     input.state = ReviewerState.CC;
     gApi.changes().id(r.getChangeId()).addReviewer(input);
     requestScopeOperations.setApiUser(newUser.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
     AuthException thrown =
         assertThrows(
             AuthException.class,
@@ -789,7 +940,11 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
         .update();
 
     // Create a change and add 'user' as reviewer.
@@ -808,7 +963,7 @@
     requestScopeOperations.setApiUser(user.id());
     approve(changeId);
     c = gApi.changes().id(changeId).get();
-    assertThat(c.labels.get("Code-Review").approved._accountId).isEqualTo(user.id().get());
+    assertThat(c.labels.get(LabelId.CODE_REVIEW).approved._accountId).isEqualTo(user.id().get());
 
     // Move 'user' from reviewer to CC.
     requestScopeOperations.setApiUser(admin.id());
@@ -826,7 +981,7 @@
     assertThat(c.reviewers.get(REVIEWER)).isNull();
 
     // Verify that the approval of 'user' is still there.
-    assertThat(c.labels.get("Code-Review").approved._accountId).isEqualTo(user.id().get());
+    assertThat(c.labels.get(LabelId.CODE_REVIEW).approved._accountId).isEqualTo(user.id().get());
   }
 
   private void assertThatUserIsOnlyReviewer(String changeId) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
index 3b26459..d3dd801 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
@@ -204,7 +204,7 @@
   public void crossDomainPutTopic() throws Exception {
     Result change = createChange();
     BasicCookieStore cookies = new BasicCookieStore();
-    Executor http = Executor.newInstance().cookieStore(cookies);
+    Executor http = Executor.newInstance().use(cookies);
 
     Request req = Request.Get(canonicalWebUrl.get() + "/login/?account_id=" + admin.id().get());
     http.execute(req);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 7fe2a50..129b546 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.entities.Permission.CREATE;
 import static com.google.gerrit.entities.Permission.READ;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static com.google.gerrit.extensions.common.testing.GitPersonSubject.assertThat;
@@ -180,6 +182,77 @@
   }
 
   @Test
+  public void cannotCreateChangeOnGerritInternalRefs() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(CREATE).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Subject";
+    ci.branch = "refs/changes/00/1000"; // disallowedRef
+
+    Throwable thrown = assertThrows(RestApiException.class, () -> gApi.changes().create(ci));
+    assertThat(thrown).hasMessageThat().contains("Cannot create a change on ref " + ci.branch);
+  }
+
+  @Test
+  public void cannotCreateChangeOnTagRefs() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(CREATE).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Subject";
+    ci.branch = "refs/tags/v1.0"; // disallowed ref
+
+    Throwable thrown = assertThrows(RestApiException.class, () -> gApi.changes().create(ci));
+    assertThat(thrown).hasMessageThat().contains("Cannot create a change on ref " + ci.branch);
+  }
+
+  @Test
+  public void canCreateChangeOnRefsMetaConfig() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(CREATE).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Subject";
+    ci.branch = RefNames.REFS_CONFIG;
+    assertThat(gApi.changes().create(ci).info().branch).isEqualTo(RefNames.REFS_CONFIG);
+  }
+
+  @Test
+  public void canCreateChangeOnRefsMetaDashboards() throws Exception {
+    String branchName = "refs/meta/dashboards/project_1";
+    requestScopeOperations.setApiUser(admin.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(CREATE).ref(branchName).group(REGISTERED_USERS))
+        .add(allow(READ).ref(branchName).group(REGISTERED_USERS))
+        .update();
+    BranchNameKey branchNameKey = BranchNameKey.create(project, branchName);
+    createBranch(branchNameKey);
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Subject";
+    ci.branch = branchName;
+    assertThat(gApi.changes().create(ci).info().branch).isEqualTo(branchName);
+  }
+
+  @Test
   public void cannotCreateChangeWithChangeIfOfExistingChangeOnSameBranch() throws Exception {
     String changeId = createChange().getChangeId();
 
@@ -708,6 +781,9 @@
     projectOperations
         .project(project)
         .forUpdate()
+        // Allow reading for refs/meta/config so that the project is visible to the user. Otherwise
+        // the request will fail with an UnprocessableEntityException "Project not found:".
+        .add(allow(READ).ref("refs/meta/config").group(REGISTERED_USERS))
         .add(block(READ).ref("refs/heads/*").group(REGISTERED_USERS))
         .update();
     requestScopeOperations.setApiUser(user.id());
@@ -731,6 +807,9 @@
     projectOperations
         .project(project)
         .forUpdate()
+        // Allow reading for refs/meta/config so that the project is visible to the user. Otherwise
+        // the request will fail with an UnprocessableEntityException "Project not found:".
+        .add(allow(READ).ref("refs/meta/config").group(REGISTERED_USERS))
         .add(block(READ).ref("refs/heads/*").group(REGISTERED_USERS))
         .update();
     requestScopeOperations.setApiUser(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index 25e5647..0c2c3a1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -92,7 +93,7 @@
     Map<String, Short> m =
         newGson().fromJson(response.getReader(), new TypeToken<Map<String, Short>>() {}.getType());
 
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
+    assertThat(m).containsExactly(LabelId.CODE_REVIEW, Short.valueOf((short) 0));
 
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
new file mode 100644
index 0000000..29dd227
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
@@ -0,0 +1,225 @@
+// 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.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInfoDifference;
+import com.google.inject.Inject;
+import java.util.Collection;
+import org.junit.Test;
+
+public class GetMetaDiffIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  private static final String UNSAVED_REV_ID = "0000000000000000000000000000000000000001";
+  private static final String TOPIC = "topic";
+  private static final String HASHTAG = "hashtag";
+
+  @Test
+  public void metaDiff() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
+    chApi.topic(TOPIC);
+    ChangeInfo oldInfo = chApi.get();
+    chApi.topic(TOPIC + "-2");
+    chApi.setHashtags(new HashtagsInput(ImmutableSet.of(HASHTAG)));
+    ChangeInfo newInfo = chApi.get();
+
+    ChangeInfoDifference difference = chApi.metaDiff(oldInfo.metaRevId, newInfo.metaRevId);
+
+    assertThat(difference.added().topic).isEqualTo(newInfo.topic);
+    assertThat(difference.added().hashtags).isNotNull();
+    assertThat(difference.added().hashtags).containsExactly(HASHTAG);
+    assertThat(difference.removed().topic).isEqualTo(oldInfo.topic);
+    assertThat(difference.removed().hashtags).isNull();
+  }
+
+  @Test
+  public void metaDiffReturnsSuccessful() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    ChangeInfo info = gApi.changes().id(ch.getChangeId()).get();
+
+    RestResponse resp =
+        adminRestSession.get("/changes/" + ch.getChangeId() + "/meta_diff/?meta=" + info.metaRevId);
+
+    resp.assertOK();
+  }
+
+  @Test
+  public void metaDiffUnreachableNewSha1() throws Exception {
+    PushOneCommit.Result ch1 = createChange();
+    PushOneCommit.Result ch2 = createChange();
+
+    ChangeInfo info2 = gApi.changes().id(ch2.getChangeId()).get();
+
+    RestResponse resp =
+        adminRestSession.get(
+            "/changes/" + ch1.getChangeId() + "/meta_diff/?meta=" + info2.metaRevId);
+
+    resp.assertStatus(412);
+  }
+
+  @Test
+  public void metaDiffInvalidNewSha1() throws Exception {
+    PushOneCommit.Result ch = createChange();
+
+    RestResponse resp =
+        adminRestSession.get("/changes/" + ch.getChangeId() + "/meta_diff/?meta=invalid");
+
+    resp.assertBadRequest();
+  }
+
+  @Test
+  public void metaDiffInvalidOldSha1() throws Exception {
+    PushOneCommit.Result ch = createChange();
+
+    RestResponse resp =
+        adminRestSession.get("/changes/" + ch.getChangeId() + "/meta_diff/?old=invalid");
+
+    resp.assertBadRequest();
+  }
+
+  @Test
+  public void metaDiffWithNewSha1NotInRepo() throws Exception {
+    PushOneCommit.Result ch = createChange();
+
+    RestResponse resp =
+        adminRestSession.get("/changes/" + ch.getChangeId() + "/meta_diff/?meta=" + UNSAVED_REV_ID);
+
+    resp.assertStatus(412);
+  }
+
+  @Test
+  public void metaDiffUnreachableOldSha1UsesDefault() throws Exception {
+    PushOneCommit.Result ch1 = createChange();
+    PushOneCommit.Result ch2 = createChange();
+    gApi.changes().id(ch1.getChangeId()).topic("intermediate-topic");
+    gApi.changes().id(ch1.getChangeId()).topic(TOPIC);
+    ChangeInfo info1 = gApi.changes().id(ch1.getChangeId()).get();
+    ChangeInfo info2 = gApi.changes().id(ch2.getChangeId()).get();
+
+    ChangeInfoDifference difference =
+        gApi.changes().id(ch1.getChangeId()).metaDiff(info2.metaRevId, info1.metaRevId);
+
+    assertThat(difference.added().topic).isEqualTo(TOPIC);
+    assertThat(difference.removed().topic).isNull();
+  }
+
+  @Test
+  public void metaDiffWithOldSha1NotInRepoUsesDefault() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    gApi.changes().id(ch.getChangeId()).topic("intermediate-topic");
+    gApi.changes().id(ch.getChangeId()).topic(TOPIC);
+    ChangeInfo info = gApi.changes().id(ch.getChangeId()).get();
+
+    ChangeInfoDifference difference =
+        gApi.changes().id(ch.getChangeId()).metaDiff(UNSAVED_REV_ID, info.metaRevId);
+
+    assertThat(difference.added().topic).isEqualTo(TOPIC);
+    assertThat(difference.removed().topic).isNull();
+  }
+
+  @Test
+  public void metaDiffNoOldMetaGivenUsesPatchSetBeforeNew() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
+    chApi.topic(TOPIC);
+    ChangeInfo newInfo = chApi.get();
+    chApi.topic(TOPIC + "2");
+
+    ChangeInfoDifference difference = chApi.metaDiff(null, newInfo.metaRevId);
+
+    assertThat(difference.added().topic).isEqualTo(TOPIC);
+    assertThat(difference.removed().topic).isNull();
+  }
+
+  @Test
+  public void metaDiffNoNewMetaGivenUsesCurrentPatchSet() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
+    ChangeInfo oldInfo = chApi.get();
+    chApi.topic(TOPIC);
+
+    ChangeInfoDifference difference = chApi.metaDiff(oldInfo.metaRevId, null);
+
+    assertThat(difference.added().topic).isEqualTo(TOPIC);
+    assertThat(difference.removed().topic).isNull();
+  }
+
+  @Test
+  public void metaDiffWithoutOptionDoesNotIncludeExtraInformation() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
+    ChangeInfo oldInfo = chApi.get();
+    amendChange(ch.getChangeId());
+    ChangeInfo newInfo = chApi.get();
+
+    ChangeInfoDifference difference = chApi.metaDiff(oldInfo.metaRevId, newInfo.metaRevId);
+
+    assertThat(difference.added().currentRevision).isNull();
+    assertThat(difference.removed().currentRevision).isNull();
+  }
+
+  @Test
+  public void metaDiffWithOptionIncludesExtraInformation() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
+    ChangeInfo oldInfo = chApi.get(ListChangesOption.CURRENT_REVISION);
+    amendChange(ch.getChangeId());
+    ChangeInfo newInfo = chApi.get(ListChangesOption.CURRENT_REVISION);
+
+    ChangeInfoDifference difference =
+        chApi.metaDiff(
+            oldInfo.metaRevId,
+            newInfo.metaRevId,
+            ImmutableSet.of(ListChangesOption.CURRENT_REVISION));
+
+    assertThat(newInfo.currentRevision).isNotNull();
+    assertThat(oldInfo.currentRevision).isNotNull();
+    assertThat(difference.added().currentRevision).isEqualTo(newInfo.currentRevision);
+    assertThat(difference.removed().currentRevision).isEqualTo(oldInfo.currentRevision);
+  }
+
+  @Test
+  public void staticField() throws Exception {
+    PushOneCommit.Result result = createChange();
+    ReviewInput in = new ReviewInput();
+    in.message("hello");
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(result.getChangeId()).revision("current").review(in);
+    ChangeApi chApi = gApi.changes().id(result.getChangeId());
+    ChangeInfoDifference difference = chApi.metaDiff(null, null, ListChangesOption.LABELS);
+    assertThat(difference.added().reviewers).containsKey(ReviewerState.CC);
+    assertThat(difference.added().reviewers).hasSize(1);
+    Collection<AccountInfo> reviewers = difference.added().reviewers.get(ReviewerState.CC);
+    assertThat(reviewers).hasSize(1);
+    AccountInfo info = reviewers.iterator().next();
+    assertThat(info._accountId).isEqualTo(user.id().get());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/LifecycleListenersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/LifecycleListenersIT.java
new file mode 100644
index 0000000..59914bc
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/LifecycleListenersIT.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.acceptance.AbstractLifecycleListenersTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.inject.Inject;
+import org.junit.Before;
+import org.junit.Test;
+
+public class LifecycleListenersIT extends AbstractLifecycleListenersTest {
+  @Inject private InvocationCheck invocationCheck;
+
+  @Before
+  public void before() {
+    invocationCheck.setStartInvoked(false);
+    invocationCheck.setStopInvoked(false);
+  }
+
+  @Test
+  public void lifecycleListenerSuccessfulInvocation() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+      RestResponse response = adminRestSession.get("/changes/?--my-plugin--opt&q=status:open");
+      response.assertOK();
+      assertTrue(invocationCheck.isStartInvoked());
+      assertTrue(invocationCheck.isStopInvoked());
+    }
+  }
+
+  @Test
+  public void lifecycleListenerUnsuccessfulInvocation() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+      RestResponse response = adminRestSession.get("/projects/");
+      response.assertOK();
+      assertFalse(invocationCheck.isStartInvoked());
+      assertFalse(invocationCheck.isStopInvoked());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index 987d646..69f1d8e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -16,20 +16,25 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.MoveInput;
@@ -188,7 +193,7 @@
         .update();
     AuthException thrown =
         assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
-    assertThat(thrown).hasMessageThat().contains("move not permitted");
+    assertThat(thrown).hasMessageThat().isEqualTo("move not permitted");
   }
 
   @Test
@@ -208,7 +213,7 @@
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
         assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
-    assertThat(thrown).hasMessageThat().contains("move not permitted");
+    assertThat(thrown).hasMessageThat().isEqualTo("move not permitted");
   }
 
   @Test
@@ -267,12 +272,230 @@
   }
 
   @Test
+  public void moveChangeKeepAllVotesOnlyAllowedForAdmins() throws Exception {
+    // Keep all votes options is only permitted for admins.
+    BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
+    createBranch(destinationBranch);
+    BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
+    createBranch(sourceBranch);
+    String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
+
+    // Grant change permissions to the registered users.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(destinationBranch.branch()).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref(sourceBranch.branch()).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> move(changeId, destinationBranch.shortName(), true));
+    assertThat(thrown).hasMessageThat().isEqualTo("move is not permitted with keepAllVotes option");
+
+    requestScopeOperations.setApiUser(admin.id());
+
+    move(changeId, destinationBranch.branch(), true);
+    assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
+  }
+
+  @Test
+  public void moveChangeKeepAllVotesNoLabelInDestination() throws Exception {
+    BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
+    createBranch(destinationBranch);
+    BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
+    createBranch(sourceBranch);
+
+    String testLabelA = "Label-A";
+    // The label has the range [-1; 1]
+    configLabel(testLabelA, LabelFunction.NO_BLOCK, ImmutableList.of(sourceBranch.branch()));
+    // Registered users have permissions for the entire range [-1; 1] on all branches.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabelA).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
+        .update();
+
+    String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput userReviewInput = new ReviewInput();
+    userReviewInput.label(testLabelA, 1);
+    gApi.changes().id(changeId).current().review(userReviewInput);
+
+    assertLabelVote(user, changeId, testLabelA, (short) 1);
+
+    requestScopeOperations.setApiUser(admin.id());
+    assertThat(atrScope.get().getUser().getAccountId()).isEqualTo(admin.id());
+
+    // Move the change to the destination branch.
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    move(changeId, destinationBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
+
+    // Label is missing in the destination branch.
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email()).votes()).isEmpty();
+
+    // Move the change back to the source, the label is kept.
+    move(changeId, sourceBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    assertLabelVote(user, changeId, testLabelA, (short) 1);
+  }
+
+  @Test
+  public void moveChangeKeepAllVotesOutOfUserPermissionRange() throws Exception {
+    BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
+    createBranch(destinationBranch);
+    BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
+    createBranch(sourceBranch);
+
+    String testLabelA = "Label-A";
+    // The label has the range [-2; 2]
+    configLabel(
+        project,
+        testLabelA,
+        LabelFunction.NO_BLOCK,
+        value(2, "Passes"),
+        value(1, "Mostly ok"),
+        value(0, "No score"),
+        value(-1, "Needs work"),
+        value(-2, "Failed"));
+    // Registered users have [-2; 2] permissions on the source.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(testLabelA).ref(sourceBranch.branch()).group(REGISTERED_USERS).range(-2, +2))
+        .update();
+
+    // Registered users have [-1; 1] permissions on the destination.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(testLabelA)
+                .ref(destinationBranch.branch())
+                .group(REGISTERED_USERS)
+                .range(-1, +1))
+        .update();
+
+    String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
+    requestScopeOperations.setApiUser(user.id());
+    // Vote within the range of the source branch.
+    ReviewInput userReviewInput = new ReviewInput();
+    userReviewInput.label(testLabelA, 2);
+    gApi.changes().id(changeId).current().review(userReviewInput);
+
+    assertLabelVote(user, changeId, testLabelA, (short) 2);
+
+    requestScopeOperations.setApiUser(admin.id());
+    assertThat(atrScope.get().getUser().getAccountId()).isEqualTo(admin.id());
+
+    // Move the change to the destination branch.
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    move(changeId, destinationBranch.branch(), true);
+    // User does not have label permissions for the same vote on the destination branch.
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(changeId).current().review(userReviewInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(String.format("Applying label \"%s\": 2 is restricted", testLabelA));
+
+    // Label is kept even though the user's permission range is different from the source.
+    // Since we do not squash users votes based on the destination branch access label
+    // configuration, this is working as intended.
+    // It's the same behavior as when a project owner reduces user's permission range on label.
+    // Administrators should take this into account.
+    assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
+    assertLabelVote(user, changeId, testLabelA, (short) 2);
+
+    requestScopeOperations.setApiUser(admin.id());
+    // Move the change back to the source, the label is kept.
+    move(changeId, sourceBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    assertLabelVote(user, changeId, testLabelA, (short) 2);
+  }
+
+  @Test
+  public void moveKeepAllVotesCanMoveAllInRange() throws Exception {
+    BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
+    createBranch(destinationBranch);
+    BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
+    createBranch(sourceBranch);
+
+    // The non-block label has the range [-2; 2]
+    String testLabelA = "Label-A";
+    configLabel(
+        project,
+        testLabelA,
+        LabelFunction.NO_BLOCK,
+        value(2, "Passes"),
+        value(1, "Mostly ok"),
+        value(0, "No score"),
+        value(-1, "Needs work"),
+        value(-2, "Failed"));
+
+    // Registered users have [-2; 2] permissions on all branches.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabelA).ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
+
+    String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
+
+    for (int vote = -2; vote <= 2; vote++) {
+      TestAccount testUser = accountCreator.create("TestUser" + vote);
+      requestScopeOperations.setApiUser(testUser.id());
+      ReviewInput userReviewInput = new ReviewInput();
+      userReviewInput.label(testLabelA, vote);
+      gApi.changes().id(changeId).current().review(userReviewInput);
+    }
+
+    assertThat(
+            gApi.changes().id(changeId).current().votes().get(testLabelA).stream()
+                .map(approvalInfo -> approvalInfo.value)
+                .collect(ImmutableList.toImmutableList()))
+        .containsExactly(-2, -1, 1, 2);
+
+    requestScopeOperations.setApiUser(admin.id());
+    // Move the change to the destination branch.
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    move(changeId, destinationBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
+
+    // All votes are kept
+    assertThat(
+            gApi.changes().id(changeId).current().votes().get(testLabelA).stream()
+                .map(approvalInfo -> approvalInfo.value)
+                .collect(ImmutableList.toImmutableList()))
+        .containsExactly(-2, -1, 1, 2);
+
+    // Move the change back to the source, the label is kept.
+    move(changeId, sourceBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    assertThat(
+            gApi.changes().id(changeId).current().votes().get(testLabelA).stream()
+                .map(approvalInfo -> approvalInfo.value)
+                .collect(ImmutableList.toImmutableList()))
+        .containsExactly(-2, -1, 1, 2);
+  }
+
+  @Test
   public void moveChangeOnlyKeepVetoVotes() throws Exception {
     // A vote for a label will be kept after moving if the label's function is *WithBlock and the
     // vote holds the minimum value.
     createBranch(BranchNameKey.create(project, "foo"));
 
-    String codeReviewLabel = "Code-Review"; // 'Code-Review' uses 'MaxWithBlock' function.
+    String codeReviewLabel = LabelId.CODE_REVIEW; // 'Code-Review' uses 'MaxWithBlock' function.
     String testLabelA = "Label-A";
     String testLabelB = "Label-B";
     String testLabelC = "Label-C";
@@ -392,10 +615,28 @@
     gApi.changes().id(changeId).move(in);
   }
 
+  private void move(String changeId, String destination, boolean keepAllVotes)
+      throws RestApiException {
+    MoveInput in = new MoveInput();
+    in.destinationBranch = destination;
+    in.keepAllVotes = keepAllVotes;
+    gApi.changes().id(changeId).move(in);
+  }
+
   private PushOneCommit.Result createChange(String branch, String changeId) throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, changeId);
     PushOneCommit.Result result = push.to("refs/for/" + branch);
     result.assertOkStatus();
     return result;
   }
+
+  private PushOneCommit.Result createChangeInBranch(String branch) throws Exception {
+    return createChange("refs/for/" + branch);
+  }
+
+  private void assertLabelVote(TestAccount user, String changeId, String label, short vote)
+      throws Exception {
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email()).votes())
+        .containsEntry(label, vote);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
index 17bf37e..a4ec40e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
-import static com.google.common.truth.Truth.assertThat;
-
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.acceptance.AbstractPluginFieldsTest;
@@ -35,41 +33,6 @@
   private static final Gson GSON = OutputFormat.JSON.newGson();
 
   @Test
-  public void queryChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))));
-  }
-
-  @Test
-  public void getChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))));
-  }
-
-  @Test
-  public void getChangeDetailWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))));
-  }
-
-  @Test
-  public void queryChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))));
-  }
-
-  @Test
-  public void getChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))));
-  }
-
-  @Test
-  public void getChangeDetailWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))));
-  }
-
-  @Test
   public void querySingleChangeWithBulkAttribute() throws Exception {
     getSingleChangeWithPluginDefinedBulkAttribute(
         id -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id))));
@@ -88,27 +51,6 @@
   }
 
   @Test
-  public void queryChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))),
-        (id, opts) -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id, opts))));
-  }
-
-  @Test
-  public void getChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))),
-        (id, opts) -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id, opts))));
-  }
-
-  @Test
-  public void getChangeDetailWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))),
-        (id, opts) -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id, opts))));
-  }
-
-  @Test
   public void pluginDefinedQueryChangeWithOption() throws Exception {
     getChangeWithPluginDefinedBulkAttributeOption(
         id -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id))),
@@ -142,12 +84,6 @@
   }
 
   @Test
-  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
-    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
-        () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
-  }
-
-  @Test
   public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
     getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
         () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
@@ -204,24 +140,6 @@
   }
 
   @Nullable
-  private static List<PluginDefinedInfo> pluginInfoFromSingletonList(RestResponse res)
-      throws Exception {
-    res.assertOK();
-    List<Map<String, Object>> changeInfos =
-        GSON.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
-    assertThat(changeInfos).hasSize(1);
-    return decodeRawPluginsList(GSON, changeInfos.get(0).get("plugins"));
-  }
-
-  @Nullable
-  private List<PluginDefinedInfo> pluginInfoFromChangeInfo(RestResponse res) throws Exception {
-    res.assertOK();
-    Map<String, Object> changeInfo =
-        GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
-    return decodeRawPluginsList(GSON, changeInfo.get("plugins"));
-  }
-
-  @Nullable
   private Map<Change.Id, List<PluginDefinedInfo>> pluginInfoMapFromChangeInfo(RestResponse res)
       throws Exception {
     res.assertOK();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 6f519f1..fff3cb6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -25,12 +25,14 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import com.google.inject.Inject;
 import java.util.List;
@@ -88,12 +90,18 @@
   @Test
   public void changeMessageOnSubmit() throws Throwable {
     PushOneCommit.Result change = createChange();
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                (newCommitMessage, original, mergeTip, destination) ->
-                    newCommitMessage + "Custom: " + destination.branch())) {
+    ChangeMessageModifier link =
+        new ChangeMessageModifier() {
+          @Override
+          public String onSubmit(
+              String newCommitMessage,
+              RevCommit original,
+              RevCommit mergeTip,
+              BranchNameKey destination) {
+            return newCommitMessage + "Custom: " + destination.branch();
+          }
+        };
+    try (Registration registration = extensionRegistry.newRegistration().add(link)) {
       submit(change.getChangeId());
     }
     testRepo.git().fetch().setRemote("origin").call();
@@ -373,4 +381,26 @@
         change2.getChangeId(),
         headAfterFirstSubmit.name());
   }
+
+  @Test
+  public void dependencyOnOutdatedPatchSetNotPreventingCherryPick() throws Throwable {
+    // Create a change
+    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+    // Create new patch set for first change.
+    testRepo.reset(changeResult.getCommit().name());
+    amendChange(changeResult.getChangeId());
+
+    // Approve both changes
+    approve(changeResult.getChangeId());
+    approve(change2Result.getChangeId());
+
+    assertSubmittable(change2Result.getChangeId());
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 1912697..66eb48c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
@@ -170,4 +171,49 @@
     assertRefUpdatedEvents(initialHead, headAfterSubmit);
     assertChangeMergedEvents(id1, headAfterSubmit.name());
   }
+
+  @Test
+  public void dependencyOnOutdatedPatchSetPreventsFastForward() throws Throwable {
+    // Create a change
+    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+    PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+    // Create new patch set for first change.
+    testRepo.reset(changeResult.getCommit().name());
+    amendChange(changeResult.getChangeId());
+
+    // Approve both changes
+    approve(changeResult.getChangeId());
+    approve(change2Result.getChangeId());
+
+    // submit button is disabled.
+    assertSubmitDisabled(change2Result.getChangeId());
+
+    submitWithConflict(
+        change2Result.getChangeId(),
+        "Failed to submit 2 changes due to the following problems:\n"
+            + "Change "
+            + change2Result.getChange().getId()
+            + ": Depends on change that was not submitted."
+            + " Commit "
+            + change2Result.getCommit().name()
+            + " depends on commit "
+            + changeResult.getCommit().name()
+            + ", which is outdated patch set "
+            + patchSetId.get()
+            + " of change "
+            + changeResult.getChange().getId()
+            + ". The latest patch set is "
+            + changeResult.getPatchSetId().get()
+            + ".");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 5fe741d..157c93c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -29,7 +29,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -534,48 +534,6 @@
   }
 
   @Test
-  public void dependencyOnOutdatedPatchSetPreventsMerge() throws Throwable {
-    // Create a change
-    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
-    PushOneCommit.Result changeResult = change.to("refs/for/master");
-    PatchSet.Id patchSetId = changeResult.getPatchSetId();
-
-    // Create a successor change.
-    PushOneCommit change2 =
-        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
-    PushOneCommit.Result change2Result = change2.to("refs/for/master");
-
-    // Create new patch set for first change.
-    testRepo.reset(changeResult.getCommit().name());
-    amendChange(changeResult.getChangeId());
-
-    // Approve both changes
-    approve(changeResult.getChangeId());
-    approve(change2Result.getChangeId());
-
-    submitWithConflict(
-        change2Result.getChangeId(),
-        "Failed to submit 2 changes due to the following problems:\n"
-            + "Change "
-            + change2Result.getChange().getId()
-            + ": Depends on change that was not submitted."
-            + " Commit "
-            + change2Result.getCommit().name()
-            + " depends on commit "
-            + changeResult.getCommit().name()
-            + ", which is outdated patch set "
-            + patchSetId.get()
-            + " of change "
-            + changeResult.getChange().getId()
-            + ". The latest patch set is "
-            + changeResult.getPatchSetId().get()
-            + ".");
-
-    assertRefUpdatedEvents();
-    assertChangeMergedEvents();
-  }
-
-  @Test
   public void dependencyOnDeletedChangePreventsMerge() throws Throwable {
     // Create a change
     PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
@@ -614,7 +572,11 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .update();
 
@@ -676,7 +638,11 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .update();
 
@@ -733,13 +699,21 @@
     projectOperations
         .project(p1)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .update();
     projectOperations
         .project(p2)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .update();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index d742fad..eeeac2a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -129,7 +129,8 @@
     ChangeMessageModifier modifier2 = (msg, orig, tip, dest) -> msg + "A-footer: value\n";
     try (Registration registration =
         extensionRegistry.newRegistration().add(modifier1).add(modifier2)) {
-      StorageException thrown = assertThrows(StorageException.class, () -> submitWithRebase());
+      InternalServerWithUserMessageException thrown =
+          assertThrows(InternalServerWithUserMessageException.class, () -> submitWithRebase());
       Throwable cause = Throwables.getRootCause(thrown);
       assertThat(cause).isInstanceOf(RuntimeException.class);
       assertThat(cause).hasMessageThat().isEqualTo("boom");
@@ -145,7 +146,8 @@
             .newRegistration()
             .add(modifier1, "modifier-1")
             .add(modifier2, "modifier-2")) {
-      StorageException thrown = assertThrows(StorageException.class, () -> submitWithRebase());
+      InternalServerWithUserMessageException thrown =
+          assertThrows(InternalServerWithUserMessageException.class, () -> submitWithRebase());
       Throwable cause = Throwables.getRootCause(thrown);
       assertThat(cause).isInstanceOf(RuntimeException.class);
       assertThat(cause)
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index cef66654..c893214 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -58,7 +58,6 @@
   @GerritConfig(name = "auth.httpPasswordUrl", value = "https://example.com/password")
 
   // change
-  @GerritConfig(name = "change.largeChange", value = "300")
   @GerritConfig(name = "change.replyTooltip", value = "Publish votes and draft comments")
   @GerritConfig(name = "change.replyLabel", value = "Vote")
   @GerritConfig(name = "change.updateDelay", value = "50s")
@@ -75,6 +74,7 @@
   @GerritConfig(name = "gerrit.allProjects", value = "Root")
   @GerritConfig(name = "gerrit.allUsers", value = "Users")
   @GerritConfig(name = "gerrit.reportBugUrl", value = "https://example.com/report")
+  @GerritConfig(name = "gerrit.instanceId", value = "devops-instance")
 
   // suggest
   @GerritConfig(name = "suggest.from", value = "3")
@@ -102,7 +102,6 @@
     assertThat(i.auth.httpPasswordUrl).isNull();
 
     // change
-    assertThat(i.change.largeChange).isEqualTo(300);
     assertThat(i.change.replyTooltip).startsWith("Publish votes and draft comments");
     assertThat(i.change.replyLabel).isEqualTo("Vote\u2026");
     assertThat(i.change.updateDelay).isEqualTo(50);
@@ -118,6 +117,7 @@
     assertThat(i.gerrit.allProjects).isEqualTo("Root");
     assertThat(i.gerrit.allUsers).isEqualTo("Users");
     assertThat(i.gerrit.reportBugUrl).isEqualTo("https://example.com/report");
+    assertThat(i.gerrit.instanceId).isEqualTo("devops-instance");
 
     // plugin
     assertThat(i.plugin.jsResourcePaths).isEmpty();
@@ -170,14 +170,12 @@
     assertThat(i.auth.httpPasswordUrl).isNull();
 
     // change
-    assertThat(i.change.largeChange).isEqualTo(500);
     assertThat(i.change.replyTooltip).startsWith("Reply and score");
     assertThat(i.change.replyLabel).isEqualTo("Reply\u2026");
     assertThat(i.change.updateDelay).isEqualTo(300);
     assertThat(i.change.disablePrivateChanges).isNull();
     assertThat(i.change.submitWholeTopic).isNull();
-    assertThat(i.change.mergeabilityComputationBehavior)
-        .isEqualTo("API_REF_UPDATED_AND_CHANGE_REINDEX");
+    assertThat(i.change.mergeabilityComputationBehavior).isEqualTo("NEVER");
 
     // download
     assertThat(i.download.archives).containsExactly("tar", "tbz2", "tgz", "txz");
@@ -187,6 +185,7 @@
     assertThat(i.gerrit.allProjects).isEqualTo(AllProjectsNameProvider.DEFAULT);
     assertThat(i.gerrit.allUsers).isEqualTo(AllUsersNameProvider.DEFAULT);
     assertThat(i.gerrit.reportBugUrl).isNull();
+    assertThat(i.gerrit.instanceId).isNull();
 
     // plugin
     assertThat(i.plugin.jsResourcePaths).isEmpty();
diff --git a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
index 9c17a5a..4453345 100644
--- a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gson.reflect.TypeToken;
 import java.util.Map;
 import org.junit.Test;
@@ -32,6 +33,7 @@
     Map<String, GroupInfo> groupMap =
         newGson()
             .fromJson(response.getReader(), new TypeToken<Map<String, GroupInfo>>() {}.getType());
-    assertThat(groupMap.keySet()).containsExactly("Administrators", "Service Users");
+    assertThat(groupMap.keySet())
+        .containsExactly("Administrators", ServiceUserClassifier.SERVICE_USERS);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 908f17a..7442425 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -81,8 +82,9 @@
 
   private static final String REFS_ALL = Constants.R_REFS + "*";
   private static final String REFS_HEADS = Constants.R_HEADS + "*";
-
-  private static final String LABEL_CODE_REVIEW = "Code-Review";
+  private static final String REFS_META_VERSION = "refs/meta/version";
+  private static final String REFS_DRAFTS = "refs/draft-comments/*";
+  private static final String REFS_STARRED_CHANGES = "refs/starred-changes/*";
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -418,13 +420,13 @@
     // Remove specific permission
     AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
     accessSectionToRemove.permissions.put(
-        Permission.LABEL + LABEL_CODE_REVIEW, newPermissionInfo());
+        Permission.LABEL + LabelId.CODE_REVIEW, newPermissionInfo());
     ProjectAccessInput removal = newProjectAccessInput();
     removal.remove.put(REFS_HEADS, accessSectionToRemove);
     pApi().access(removal);
 
     // Remove locally
-    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LABEL_CODE_REVIEW);
+    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LabelId.CODE_REVIEW);
 
     // Check
     assertThat(pApi().access().local).isEqualTo(accessInput.add);
@@ -442,10 +444,10 @@
     // Remove specific permission rule
     AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
     PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LABEL_CODE_REVIEW;
+    codeReview.label = LabelId.CODE_REVIEW;
     PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
     codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionToRemove.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
+    accessSectionToRemove.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
     ProjectAccessInput removal = newProjectAccessInput();
     removal.remove.put(REFS_HEADS, accessSectionToRemove);
     pApi().access(removal);
@@ -455,7 +457,7 @@
         .add
         .get(REFS_HEADS)
         .permissions
-        .get(Permission.LABEL + LABEL_CODE_REVIEW)
+        .get(Permission.LABEL + LabelId.CODE_REVIEW)
         .rules
         .remove(SystemGroupBackend.REGISTERED_USERS.get());
 
@@ -475,18 +477,18 @@
     // Remove specific permission rules
     AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
     PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LABEL_CODE_REVIEW;
+    codeReview.label = LabelId.CODE_REVIEW;
     PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
     codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
     pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
     codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSectionToRemove.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
+    accessSectionToRemove.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
     ProjectAccessInput removal = newProjectAccessInput();
     removal.remove.put(REFS_HEADS, accessSectionToRemove);
     pApi().access(removal);
 
     // Remove locally
-    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LABEL_CODE_REVIEW);
+    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LabelId.CODE_REVIEW);
 
     // Check
     assertThat(pApi().access().local).isEqualTo(accessInput.add);
@@ -499,7 +501,10 @@
     AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
 
     // Disallow READ
-    accessInput.add.put(REFS_ALL, accessSectionInfo);
+    accessInput.add.put(REFS_META_VERSION, accessSectionInfo);
+    accessInput.add.put(REFS_DRAFTS, accessSectionInfo);
+    accessInput.add.put(REFS_STARRED_CHANGES, accessSectionInfo);
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
     pApi().access(accessInput);
 
     requestScopeOperations.setApiUser(user.id());
@@ -513,7 +518,10 @@
     AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
 
     // Disallow READ
-    accessInput.add.put(REFS_ALL, accessSectionInfo);
+    accessInput.add.put(REFS_META_VERSION, accessSectionInfo);
+    accessInput.add.put(REFS_DRAFTS, accessSectionInfo);
+    accessInput.add.put(REFS_STARRED_CHANGES, accessSectionInfo);
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
     pApi().access(accessInput);
 
     // Create a change to apply
@@ -1056,7 +1064,7 @@
     accessSection.permissions.put(Permission.PUSH, push);
 
     PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LABEL_CODE_REVIEW;
+    codeReview.label = LabelId.CODE_REVIEW;
     pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
     codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
 
@@ -1064,7 +1072,7 @@
     pri.max = 1;
     pri.min = -1;
     codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSection.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
+    accessSection.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
 
     return accessSection;
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
index 5e1fc83..edcb1f9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
@@ -20,7 +20,6 @@
         "LabelAssert.java",
     ],
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
index a2f976a..b0320f6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
@@ -233,7 +233,11 @@
         .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
         .call();
 
-    assertBadRequest("master", "fdsafsdf", "recursive", "Cannot resolve 'fdsafsdf' to a commit");
+    assertBadRequest(
+        "master",
+        "fdsafsdf",
+        "recursive",
+        "Error resolving: 'fdsafsdf'. Do not have read permission, or failed to resolve to a commit.");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 096c72b..93ce255 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -207,7 +207,7 @@
   }
 
   @Test
-  public void createUserBranch_Conflict() throws Exception {
+  public void createUserBranch_NotAllowed() throws Exception {
     projectOperations
         .project(allUsers)
         .forUpdate()
@@ -217,12 +217,12 @@
     assertCreateFails(
         BranchNameKey.create(allUsers, RefNames.refsUsers(Account.id(1))),
         RefNames.refsUsers(admin.id()),
-        ResourceConflictException.class,
-        "Not allowed to create user branch.");
+        BadRequestException.class,
+        "Not allowed to create branches under Gerrit internal or tags refs.");
   }
 
   @Test
-  public void createGroupBranch_Conflict() throws Exception {
+  public void createGroupBranch_NotAllowed() throws Exception {
     projectOperations
         .project(allUsers)
         .forUpdate()
@@ -232,8 +232,8 @@
     assertCreateFails(
         BranchNameKey.create(allUsers, RefNames.refsGroups(AccountGroup.uuid("foo"))),
         RefNames.refsGroups(adminGroupUuid()),
-        ResourceConflictException.class,
-        "Not allowed to create group branch.");
+        BadRequestException.class,
+        "Not allowed to create branches under Gerrit internal or tags refs.");
   }
 
   @Test
@@ -355,6 +355,22 @@
   }
 
   @Test
+  public void cannotCreateBranchInGerritInternalRefsNamespace() throws Exception {
+    assertCreateFails(
+        BranchNameKey.create(project, RefNames.REFS_CHANGES + "00/1000"),
+        BadRequestException.class,
+        "Not allowed to create branches under Gerrit internal or tags refs.");
+  }
+
+  @Test
+  public void cannotCreateBranchInTagsNamespace() throws Exception {
+    assertCreateFails(
+        BranchNameKey.create(project, RefNames.REFS_TAGS + "v1.0"),
+        BadRequestException.class,
+        "Not allowed to create branches under Gerrit internal or tags refs.");
+  }
+
+  @Test
   public void cannotCreateBranchWithInvalidName() throws Exception {
     assertCreateFails(
         BranchNameKey.create(project, RefNames.REFS_HEADS),
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index 94511f8..6a98b8b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
@@ -95,7 +96,7 @@
             () ->
                 gApi.projects()
                     .name(allProjects.get())
-                    .label("Code-Review")
+                    .label(LabelId.CODE_REVIEW)
                     .create(new LabelDefinitionInput()));
     assertThat(thrown).hasMessageThat().contains("label Code-Review already exists");
   }
@@ -240,6 +241,7 @@
     assertThat(createdLabel.copyAnyScore).isNull();
     assertThat(createdLabel.copyMinScore).isNull();
     assertThat(createdLabel.copyMaxScore).isNull();
+    assertThat(createdLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
     assertThat(createdLabel.copyAllScoresIfNoChange).isTrue();
     assertThat(createdLabel.copyAllScoresIfNoCodeChange).isNull();
     assertThat(createdLabel.copyAllScoresOnTrivialRebase).isNull();
@@ -450,6 +452,28 @@
   }
 
   @Test
+  public void createWithCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresIfListOfFilesDidNotChange = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresIfListOfFilesDidNotChange = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
+  }
+
+  @Test
   public void createWithCopyAllScoresIfNoChange() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
index 10fd65f..7090074 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -26,6 +26,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.common.net.HttpHeaders;
@@ -40,6 +41,7 @@
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -47,12 +49,14 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import java.util.Collections;
 import java.util.Optional;
@@ -303,13 +307,15 @@
   }
 
   @Test
-  public void createProjectWithEmptyCommit() throws Exception {
+  @GerritConfig(name = "gerrit.defaultBranch", value = "main")
+  public void createPermissionOnlyProject_WhenDefaultBranchIsSet() throws Exception {
     String newProjectName = name("newProject");
     ProjectInput in = new ProjectInput();
     in.name = newProjectName;
-    in.createEmptyCommit = true;
+    in.permissionsOnly = true;
     gApi.projects().create(in);
-    assertEmptyCommit(newProjectName, "refs/heads/master");
+    // For permissionOnly, don't use host-level default branch.
+    assertHead(newProjectName, RefNames.REFS_CONFIG);
   }
 
   @Test
@@ -328,6 +334,87 @@
   }
 
   @Test
+  public void createProjectWithInvalidBranch() throws Exception {
+    String newProjectName = name("newProject");
+    ProjectInput in = new ProjectInput();
+    in.name = newProjectName;
+    in.createEmptyCommit = true;
+    in.branches = ImmutableList.of("refs/heads/test", "refs/changes/34/1234");
+    Throwable thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.projects().create(in));
+    assertThat(thrown).hasCauseThat().isInstanceOf(ValidationException.class);
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot create a project with branch refs/changes/34/1234");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.defaultBranch", value = "main")
+  public void createProject_WhenDefaultBranchIsSet() throws Exception {
+    String newProjectName = name("newProject");
+    gApi.projects().create(newProjectName).get();
+    ImmutableMap<String, BranchInfo> branches = getProjectBranches(newProjectName);
+    // HEAD symbolic ref is set to the default, but the actual ref is not created.
+    assertThat(branches.keySet()).containsExactly("HEAD", "refs/meta/config");
+    assertHead(newProjectName, "refs/heads/main");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.defaultBranch", value = "main")
+  public void createProjectWithEmptyCommit_WhenDefaultBranchIsSet() throws Exception {
+    String newProjectName = name("newProject");
+    ProjectInput in = new ProjectInput();
+    in.name = newProjectName;
+    in.createEmptyCommit = true;
+    gApi.projects().create(in);
+    ImmutableMap<String, BranchInfo> branches = getProjectBranches(newProjectName);
+    // HEAD symbolic ref is set to the default, and the actual ref is created.
+    assertThat(branches.keySet()).containsExactly("HEAD", "refs/meta/config", "refs/heads/main");
+    assertHead(newProjectName, "refs/heads/main");
+    assertEmptyCommit(newProjectName, "HEAD", "refs/heads/main");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.defaultBranch", value = "refs/heads/main")
+  public void createProject_WhenDefaultBranchIsSet_WithBranches() throws Exception {
+    // Host-level default only applies if no branches were passed in the input
+    String newProjectName = name("newProject");
+    ProjectInput in = new ProjectInput();
+    in.name = newProjectName;
+    in.createEmptyCommit = true;
+    in.branches = ImmutableList.of("refs/heads/test", "release");
+    gApi.projects().create(in);
+    ImmutableMap<String, BranchInfo> branches = getProjectBranches(newProjectName);
+    assertThat(branches.keySet())
+        .containsExactly("HEAD", "refs/meta/config", "refs/heads/test", "refs/heads/release");
+    assertHead(newProjectName, "refs/heads/test");
+    assertEmptyCommit(newProjectName, "refs/heads/test", "refs/heads/release");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.defaultBranch", value = "refs/users/self")
+  public void createProject_WhenDefaultBranchIsSet_ToGerritRef() throws Exception {
+    String newProjectName = name("newProject");
+    Throwable thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.projects().create(newProjectName));
+    assertThat(thrown).hasCauseThat().isInstanceOf(ValidationException.class);
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot create a project with branch refs/users/self");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.defaultBranch", value = "refs~main")
+  public void createProject_WhenDefaultBranchIsSet_ToInvalidBranch() throws Exception {
+    String newProjectName = name("newProject");
+    Throwable thrown =
+        assertThrows(BadRequestException.class, () -> gApi.projects().create(newProjectName));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Branch \"refs/heads/refs~main\" is not a valid name.");
+  }
+
+  @Test
   public void createProjectWithCapability() throws Exception {
     projectOperations
         .allProjectsForUpdate()
@@ -478,12 +565,6 @@
     assertThat(cfg.defaultSubmitType.inheritedValue).isEqualTo(SubmitType.MERGE_ALWAYS);
   }
 
-  private void assertHead(String projectName, String expectedRef) throws Exception {
-    try (Repository repo = repoManager.openRepository(Project.nameKey(projectName))) {
-      assertThat(repo.exactRef(Constants.HEAD).getTarget().getName()).isEqualTo(expectedRef);
-    }
-  }
-
   private void assertEmptyCommit(String projectName, String... refs) throws Exception {
     Project.NameKey projectKey = Project.nameKey(projectName);
     try (Repository repo = repoManager.openRepository(projectKey);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index c98a58e..ce92536 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -128,6 +128,7 @@
     projectOperations
         .project(project)
         .forUpdate()
+        .add(allow(Permission.READ).ref(metaRef).group(REGISTERED_USERS))
         .add(allow(Permission.CREATE).ref(metaRef).group(REGISTERED_USERS))
         .add(allow(Permission.PUSH).ref(metaRef).group(REGISTERED_USERS))
         .update();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
index 57c7b17..c2db9f1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -39,7 +40,7 @@
     AuthException thrown =
         assertThrows(
             AuthException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").delete());
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete());
     assertThat(thrown).hasMessageThat().contains("Authentication required");
   }
 
@@ -55,7 +56,7 @@
     AuthException thrown =
         assertThrows(
             AuthException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").delete());
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete());
     assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
   }
 
@@ -70,18 +71,18 @@
 
   @Test
   public void delete() throws Exception {
-    gApi.projects().name(allProjects.get()).label("Code-Review").delete();
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete();
 
     ResourceNotFoundException thrown =
         assertThrows(
             ResourceNotFoundException.class,
-            () -> gApi.projects().name(project.get()).label("Code-Review").get());
+            () -> gApi.projects().name(project.get()).label(LabelId.CODE_REVIEW).get());
     assertThat(thrown).hasMessageThat().contains("Not found: Code-Review");
   }
 
   @Test
   public void defaultCommitMessage() throws Exception {
-    gApi.projects().name(allProjects.get()).label("Code-Review").delete();
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete();
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Delete label");
@@ -89,7 +90,10 @@
 
   @Test
   public void withCommitMessage() throws Exception {
-    gApi.projects().name(allProjects.get()).label("Code-Review").delete("Delete Code-Review label");
+    gApi.projects()
+        .name(allProjects.get())
+        .label(LabelId.CODE_REVIEW)
+        .delete("Delete Code-Review label");
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Delete Code-Review label");
@@ -99,7 +103,7 @@
   public void commitMessageIsTrimmed() throws Exception {
     gApi.projects()
         .name(allProjects.get())
-        .label("Code-Review")
+        .label(LabelId.CODE_REVIEW)
         .delete(" Delete Code-Review label ");
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java
new file mode 100644
index 0000000..74ba48e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.server.restapi.project.FilesInCommitCollection;
+import java.util.Map;
+import java.util.function.Function;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test class for {@link FilesInCommitCollection}. */
+public class FilesInCommitIT extends AbstractDaemonTest {
+  private String changeId;
+
+  @Before
+  public void setUp() throws Exception {
+    baseConfig.setString("cache", "diff", "timeout", "1 minute");
+
+    ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
+    addCommit(
+        headCommit,
+        ImmutableMap.of("file_1.txt", "file 1 content", "file_2.txt", "file 2 content"));
+
+    Result result = createEmptyChange();
+    changeId = result.getChangeId();
+  }
+
+  @Test
+  public void listFilesForSingleParentCommit() throws Exception {
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .modifyFile("a_new_file.txt", RawInputUtil.create("Line 1\nLine 2\nLine 3"));
+    gApi.changes().id(changeId).edit().deleteFile("file_1.txt");
+    gApi.changes().id(changeId).edit().publish();
+
+    String lastCommitId = gApi.changes().id(changeId).get().currentRevision;
+
+    // When parentNum is 0, the diff is performed against the default base, i.e. the single parent
+    // in this case.
+    Map<String, FileInfo> changedFiles =
+        gApi.projects().name(project.get()).commit(lastCommitId).files(0);
+
+    assertThat(changedFiles.keySet())
+        .containsExactly("/COMMIT_MSG", "a_new_file.txt", "file_1.txt");
+  }
+
+  @Test
+  public void listFilesForMergeCommitAgainstParent1() throws Exception {
+    PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
+
+    String changeId = result.getChangeId();
+    addModifiedPatchSet(changeId, "my_file.txt", content -> content.concat("Line I\nLine II\n"));
+
+    String lastCommitId = gApi.changes().id(changeId).get().currentRevision;
+
+    // Diffing against the first parent.
+    Map<String, FileInfo> changedFiles =
+        gApi.projects().name(project.get()).commit(lastCommitId).files(1);
+
+    assertThat(changedFiles.keySet())
+        .containsExactly(
+            "/COMMIT_MSG",
+            "/MERGE_LIST",
+            "bar", // file bar is coming from parent two
+            "my_file.txt");
+  }
+
+  @Test
+  public void listFilesForMergeCommitAgainstDefaultParent() throws Exception {
+    PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
+
+    String changeId = result.getChangeId();
+    addModifiedPatchSet(changeId, "my_file.txt", content -> content.concat("Line I\nLine II\n"));
+
+    String lastCommitId = gApi.changes().id(changeId).get().currentRevision;
+
+    // When parentNum is 0, the diff is performed against the default base. In this case, the
+    // auto-merge commit.
+    Map<String, FileInfo> changedFiles =
+        gApi.projects().name(project.get()).commit(lastCommitId).files(0);
+
+    assertThat(changedFiles.keySet())
+        .containsExactly(
+            "/COMMIT_MSG",
+            "/MERGE_LIST",
+            "bar", // file bar is coming from parent two
+            "my_file.txt");
+  }
+
+  private void addModifiedPatchSet(
+      String changeId, String filePath, Function<String, String> contentModification)
+      throws Exception {
+    try (BinaryResult content = gApi.changes().id(changeId).current().file(filePath).content()) {
+      String newContent = contentModification.apply(content.asString());
+      gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(newContent));
+    }
+    gApi.changes().id(changeId).edit().publish();
+  }
+
+  private ObjectId addCommit(ObjectId parentCommit, ImmutableMap<String, String> files)
+      throws Exception {
+    testRepo.reset(parentCommit);
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Adjust files of repo", files);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    return result.getCommit();
+  }
+
+  private Result createEmptyChange() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Test change", ImmutableMap.of());
+    return push.to("refs/for/master");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
index b4b1be0..13c20dd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
@@ -262,9 +262,12 @@
     requestScopeOperations.setApiUser(user.id());
     assertBranchFound(allUsers, RefNames.refsUsers(user.id()));
 
-    // TODO: every user can see the own user ref via the magic ref/users/self ref
-    // requestScopeOperations.setApiUser(user.id());
-    // assertBranchFound(allUsers, RefNames.REFS_USERS_SELF);
+    // every user can see the own user ref via the magic ref/users/self ref. For this special case,
+    // the branch in the request is refs/users/self, but the response contains the actual
+    // refs/users/$sharded_id/$id
+    BranchInfo branchInfo =
+        gApi.projects().name(allUsers.get()).branch(RefNames.REFS_USERS_SELF).get();
+    assertThat(branchInfo.ref).isEqualTo(RefNames.refsUsers(user.id()));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
index a2c5c64..302d827 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -38,7 +39,7 @@
     AuthException thrown =
         assertThrows(
             AuthException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get());
     assertThat(thrown).hasMessageThat().contains("Authentication required");
   }
 
@@ -48,7 +49,7 @@
     AuthException thrown =
         assertThrows(
             AuthException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get());
     assertThat(thrown).hasMessageThat().contains("read refs/meta/config not permitted");
   }
 
@@ -64,7 +65,7 @@
   @Test
   public void allProjectsCodeReviewLabel() throws Exception {
     LabelDefinitionInfo codeReviewLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").get();
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get();
     LabelAssert.assertCodeReviewLabel(codeReviewLabel);
   }
 
@@ -113,6 +114,7 @@
     assertThat(fooLabel.copyAnyScore).isNull();
     assertThat(fooLabel.copyMinScore).isNull();
     assertThat(fooLabel.copyMaxScore).isNull();
+    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
     assertThat(fooLabel.copyAllScoresIfNoChange).isNull();
     assertThat(fooLabel.copyAllScoresIfNoCodeChange).isNull();
     assertThat(fooLabel.copyAllScoresOnTrivialRebase).isNull();
@@ -135,6 +137,7 @@
                 labelType.setCopyAnyScore(true);
                 labelType.setCopyMinScore(true);
                 labelType.setCopyMaxScore(true);
+                labelType.setCopyAllScoresIfListOfFilesDidNotChange(true);
                 labelType.setCopyAllScoresIfNoCodeChange(true);
                 labelType.setCopyAllScoresOnTrivialRebase(true);
                 labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
@@ -149,6 +152,7 @@
     assertThat(fooLabel.copyAnyScore).isTrue();
     assertThat(fooLabel.copyMinScore).isTrue();
     assertThat(fooLabel.copyMaxScore).isTrue();
+    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
     assertThat(fooLabel.copyAllScoresIfNoChange).isTrue();
     assertThat(fooLabel.copyAllScoresIfNoCodeChange).isTrue();
     assertThat(fooLabel.copyAllScoresOnTrivialRebase).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
index 201bb53..9e31026 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
@@ -17,12 +17,13 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 
 public class LabelAssert {
   public static void assertCodeReviewLabel(LabelDefinitionInfo codeReviewLabel) {
-    assertThat(codeReviewLabel.name).isEqualTo("Code-Review");
+    assertThat(codeReviewLabel.name).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(codeReviewLabel.projectName).isEqualTo(AllProjectsNameProvider.DEFAULT);
     assertThat(codeReviewLabel.function).isEqualTo(LabelFunction.MAX_WITH_BLOCK.getFunctionName());
     assertThat(codeReviewLabel.values)
@@ -43,6 +44,7 @@
     assertThat(codeReviewLabel.copyAnyScore).isNull();
     assertThat(codeReviewLabel.copyMinScore).isTrue();
     assertThat(codeReviewLabel.copyMaxScore).isNull();
+    assertThat(codeReviewLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
     assertThat(codeReviewLabel.copyAllScoresIfNoChange).isTrue();
     assertThat(codeReviewLabel.copyAllScoresIfNoCodeChange).isNull();
     assertThat(codeReviewLabel.copyAllScoresOnTrivialRebase).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
index 7535dea..3c8357b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
@@ -16,13 +16,21 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 import org.apache.commons.lang.RandomStringUtils;
 import org.junit.Test;
@@ -30,6 +38,8 @@
 @NoHttpd
 public class ListChildProjectsIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
+  @Inject private GroupOperations groupOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
   public void listChildrenOfNonExistingProject_NotFound() throws Exception {
@@ -84,4 +94,29 @@
         .containsExactly(child1_1, child1_1_1, child1_1_1_1, child1_2)
         .inOrder();
   }
+
+  @Test
+  public void listChildrenVisibility() throws Exception {
+    Project.NameKey parent = projectOperations.newProject().createEmptyCommit(true).create();
+    Project.NameKey project =
+        projectOperations.newProject().createEmptyCommit(true).parent(parent).create();
+
+    AccountGroup.UUID privilegedGroupUuid =
+        groupOperations.newGroup().name(name("privilegedGroup")).create();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(privilegedGroupUuid))
+        .add(block(Permission.READ).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
+
+    TestAccount privilegedUser =
+        accountCreator.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden", null);
+    groupOperations.group(privilegedGroupUuid).forUpdate().addMember(privilegedUser.id()).update();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThat(gApi.projects().name(parent.get()).children(false)).isEmpty();
+    requestScopeOperations.setApiUser(privilegedUser.id());
+    assertThat(gApi.projects().name(parent.get()).children(false)).isNotEmpty();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
index d39c96e..a397693 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -65,7 +66,7 @@
   @Test
   public void allProjectsLabels() throws Exception {
     List<LabelDefinitionInfo> labels = gApi.projects().name(allProjects.get()).labels().get();
-    assertThat(labelNames(labels)).containsExactly("Code-Review");
+    assertThat(labelNames(labels)).containsExactly(LabelId.CODE_REVIEW);
 
     LabelDefinitionInfo codeReviewLabel = Iterables.getOnlyElement(labels);
     LabelAssert.assertCodeReviewLabel(codeReviewLabel);
@@ -135,6 +136,7 @@
     assertThat(fooLabel.copyAnyScore).isNull();
     assertThat(fooLabel.copyMinScore).isNull();
     assertThat(fooLabel.copyMaxScore).isNull();
+    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
     assertThat(fooLabel.copyAllScoresIfNoChange).isNull();
     assertThat(fooLabel.copyAllScoresIfNoCodeChange).isNull();
     assertThat(fooLabel.copyAllScoresOnTrivialRebase).isNull();
@@ -157,6 +159,7 @@
                 labelType.setCopyAnyScore(true);
                 labelType.setCopyMinScore(true);
                 labelType.setCopyMaxScore(true);
+                labelType.setCopyAllScoresIfListOfFilesDidNotChange(true);
                 labelType.setCopyAllScoresIfNoCodeChange(true);
                 labelType.setCopyAllScoresOnTrivialRebase(true);
                 labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
@@ -174,6 +177,7 @@
     assertThat(fooLabel.copyAnyScore).isTrue();
     assertThat(fooLabel.copyMinScore).isTrue();
     assertThat(fooLabel.copyMaxScore).isTrue();
+    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
     assertThat(fooLabel.copyAllScoresIfNoChange).isTrue();
     assertThat(fooLabel.copyAllScoresIfNoCodeChange).isTrue();
     assertThat(fooLabel.copyAllScoresOnTrivialRebase).isTrue();
@@ -210,7 +214,7 @@
   public void inheritedLabelsOnly() throws Exception {
     List<LabelDefinitionInfo> labels =
         gApi.projects().name(project.get()).labels().withInherited(true).get();
-    assertThat(labelNames(labels)).containsExactly("Code-Review");
+    assertThat(labelNames(labels)).containsExactly(LabelId.CODE_REVIEW);
 
     LabelDefinitionInfo codeReviewLabel = Iterables.getOnlyElement(labels);
     LabelAssert.assertCodeReviewLabel(codeReviewLabel);
@@ -224,7 +228,9 @@
 
     List<LabelDefinitionInfo> labels =
         gApi.projects().name(project.get()).labels().withInherited(true).get();
-    assertThat(labelNames(labels)).containsExactly("Code-Review", "bar", "baz", "foo").inOrder();
+    assertThat(labelNames(labels))
+        .containsExactly(LabelId.CODE_REVIEW, "bar", "baz", "foo")
+        .inOrder();
 
     LabelAssert.assertCodeReviewLabel(labels.get(0));
     assertThat(labels.get(1).name).isEqualTo("bar");
@@ -237,14 +243,14 @@
 
   @Test
   public void withInheritedLabelsAndOverriddenLabel() throws Exception {
-    configLabel("Code-Review", LabelFunction.NO_OP);
+    configLabel(LabelId.CODE_REVIEW, LabelFunction.NO_OP);
 
     List<LabelDefinitionInfo> labels =
         gApi.projects().name(project.get()).labels().withInherited(true).get();
-    assertThat(labelNames(labels)).containsExactly("Code-Review", "Code-Review");
+    assertThat(labelNames(labels)).containsExactly(LabelId.CODE_REVIEW, LabelId.CODE_REVIEW);
 
     LabelAssert.assertCodeReviewLabel(labels.get(0));
-    assertThat(labels.get(1).name).isEqualTo("Code-Review");
+    assertThat(labels.get(1).name).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(labels.get(1).projectName).isEqualTo(project.get());
     assertThat(labels.get(1).function).isEqualTo(LabelFunction.NO_OP.getFunctionName());
   }
@@ -259,7 +265,7 @@
 
     List<LabelDefinitionInfo> labels =
         gApi.projects().name(childProject.get()).labels().withInherited(true).get();
-    assertThat(labelNames(labels)).containsExactly("Code-Review", "foo", "bar").inOrder();
+    assertThat(labelNames(labels)).containsExactly(LabelId.CODE_REVIEW, "foo", "bar").inOrder();
 
     LabelAssert.assertCodeReviewLabel(labels.get(0));
     assertThat(labels.get(1).name).isEqualTo("foo");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
index ba52024..cbaba2e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.BatchLabelInput;
@@ -124,7 +125,7 @@
   @Test
   public void cannotCreateLabelWithNameThatIsAlreadyInUse() throws Exception {
     LabelDefinitionInput labelInput = new LabelDefinitionInput();
-    labelInput.name = "Code-Review";
+    labelInput.name = LabelId.CODE_REVIEW;
     BatchLabelInput input = new BatchLabelInput();
     input.create = ImmutableList.of(labelInput);
 
@@ -319,7 +320,7 @@
     labelInput.commitMessage = "Update label";
 
     BatchLabelInput input = new BatchLabelInput();
-    input.update = ImmutableMap.of("Code-Review", labelInput);
+    input.update = ImmutableMap.of(LabelId.CODE_REVIEW, labelInput);
 
     BadRequestException thrown =
         assertThrows(
@@ -425,7 +426,7 @@
   @Test
   public void defaultCommitMessage() throws Exception {
     BatchLabelInput input = new BatchLabelInput();
-    input.delete = ImmutableList.of("Code-Review");
+    input.delete = ImmutableList.of(LabelId.CODE_REVIEW);
     gApi.projects().name(allProjects.get()).labels(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
@@ -436,7 +437,7 @@
   public void withCommitMessage() throws Exception {
     BatchLabelInput input = new BatchLabelInput();
     input.commitMessage = "Batch Update Labels";
-    input.delete = ImmutableList.of("Code-Review");
+    input.delete = ImmutableList.of(LabelId.CODE_REVIEW);
     gApi.projects().name(allProjects.get()).labels(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
@@ -447,7 +448,7 @@
   public void commitMessageIsTrimmed() throws Exception {
     BatchLabelInput input = new BatchLabelInput();
     input.commitMessage = " Batch Update Labels ";
-    input.delete = ImmutableList.of("Code-Review");
+    input.delete = ImmutableList.of(LabelId.CODE_REVIEW);
     gApi.projects().name(allProjects.get()).labels(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
index 5fd55ec..83d2256 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
@@ -22,16 +22,23 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import java.util.Arrays;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
 
 public class ProjectLevelConfigIT extends AbstractDaemonTest {
+  private static final String PLUGIN_NAME = "test-plugin";
+
   @Inject private ProjectOperations projectOperations;
+  @Inject private PluginConfigFactory pluginConfigFactory;
 
   @Before
   public void setUp() throws Exception {
@@ -168,7 +175,7 @@
     expectedCfg.setString("s3", null, "k5", "childValue3");
     expectedCfg.setString("s3", "ss", "k6", "childValue4");
 
-    assertThat(state.getConfig(configName).getWithInheritance(true).toText())
+    assertThat(state.getConfig(configName).getWithInheritance(/* merge= */ true).toText())
         .isEqualTo(expectedCfg.toText());
 
     assertThat(state.getConfig(configName).get().toText()).isEqualTo(cfg.toText());
@@ -185,4 +192,27 @@
     ProjectState state = projectCache.get(project).get();
     assertThat(state.getConfig(configName).get().toText()).isEmpty();
   }
+
+  @Test
+  public void emptySubSectionsCanBeRead() throws Exception {
+    updatePluginConfig(project, "[section \"subsection\"]");
+    Config cfg = pluginConfigFactory.getProjectPluginConfigWithInheritance(project, PLUGIN_NAME);
+    assertThat(cfg.getSubsections("section")).containsExactly("subsection");
+  }
+
+  private void updatePluginConfig(Project.NameKey project, String pluginConfig) throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = testRepo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = testRepo.getRevWalk().parseCommit(ref.getObjectId());
+      testRepo.update(
+          RefNames.REFS_CONFIG,
+          testRepo
+              .commit()
+              .parent(head)
+              .message("Configure plugin")
+              .add(PLUGIN_NAME + ".config", pluginConfig));
+    }
+    projectCache.evict(project);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index 1e8d978..2e68b54 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
@@ -52,7 +53,7 @@
             () ->
                 gApi.projects()
                     .name(allProjects.get())
-                    .label("Code-Review")
+                    .label(LabelId.CODE_REVIEW)
                     .update(new LabelDefinitionInput()));
     assertThat(thrown).hasMessageThat().contains("Authentication required");
   }
@@ -72,7 +73,7 @@
             () ->
                 gApi.projects()
                     .name(allProjects.get())
-                    .label("Code-Review")
+                    .label(LabelId.CODE_REVIEW)
                     .update(new LabelDefinitionInput()));
     assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
   }
@@ -83,13 +84,13 @@
     input.name = "Foo-Review";
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.name).isEqualTo(input.name);
 
     assertThat(gApi.projects().name(allProjects.get()).label("Foo-Review").get()).isNotNull();
     assertThrows(
         ResourceNotFoundException.class,
-        () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+        () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get());
   }
 
   @Test
@@ -98,13 +99,13 @@
     input.name = " Foo-Review ";
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.name).isEqualTo("Foo-Review");
 
     assertThat(gApi.projects().name(allProjects.get()).label("Foo-Review").get()).isNotNull();
     assertThrows(
         ResourceNotFoundException.class,
-        () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+        () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get());
   }
 
   @Test
@@ -115,7 +116,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("name cannot be empty");
   }
 
@@ -127,7 +128,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("invalid name: " + input.name);
   }
 
@@ -169,10 +170,10 @@
     input.function = LabelFunction.NO_OP.getFunctionName();
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.function).isEqualTo(input.function);
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().function)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().function)
         .isEqualTo(input.function);
   }
 
@@ -182,10 +183,10 @@
     input.function = " " + LabelFunction.NO_OP.getFunctionName() + " ";
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.function).isEqualTo(LabelFunction.NO_OP.getFunctionName());
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().function)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().function)
         .isEqualTo(LabelFunction.NO_OP.getFunctionName());
   }
 
@@ -197,7 +198,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("function cannot be empty");
   }
 
@@ -209,7 +210,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("unknown function: " + input.function);
   }
 
@@ -221,7 +222,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("values cannot be empty");
   }
 
@@ -243,7 +244,7 @@
             "Looks Very Bad");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.values)
         .containsExactly(
             "+2", "Looks Very Good",
@@ -252,7 +253,7 @@
             "-1", "Looks Bad",
             "-2", "Looks Very Bad");
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().values)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().values)
         .containsExactly(
             "+2", "Looks Very Good",
             "+1", "Looks Good",
@@ -279,7 +280,7 @@
             " Looks Very Bad ");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.values)
         .containsExactly(
             "+2", "Looks Very Good",
@@ -288,7 +289,7 @@
             "-1", "Looks Bad",
             "-2", "Looks Very Bad");
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().values)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().values)
         .containsExactly(
             "+2", "Looks Very Good",
             "+1", "Looks Good",
@@ -305,7 +306,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("invalid value: invalidValue");
   }
 
@@ -317,7 +318,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("description for value '+1' cannot be empty");
   }
 
@@ -332,7 +333,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("duplicate value: 1");
   }
 
@@ -342,10 +343,11 @@
     input.defaultValue = 1;
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.defaultValue).isEqualTo(input.defaultValue);
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().defaultValue)
+    assertThat(
+            gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().defaultValue)
         .isEqualTo(input.defaultValue);
   }
 
@@ -357,7 +359,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("invalid default value: " + input.defaultValue);
   }
 
@@ -369,10 +371,10 @@
         ImmutableList.of("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.branches).containsExactlyElementsIn(input.branches);
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .containsExactlyElementsIn(input.branches);
   }
 
@@ -383,11 +385,11 @@
         ImmutableList.of(" refs/heads/master ", " refs/heads/foo/* ", " ^refs/heads/stable-.* ");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.branches)
         .containsExactly("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .containsExactly("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
   }
 
@@ -397,10 +399,10 @@
     input.branches = ImmutableList.of("refs/heads/master", "", " ");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.branches).containsExactly("refs/heads/master");
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .containsExactly("refs/heads/master");
   }
 
@@ -408,15 +410,15 @@
   public void branchesCanBeUnset() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.branches = ImmutableList.of("refs/heads/master");
-    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .isNotNull();
 
     input.branches = ImmutableList.of();
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.branches).isNull();
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .isNull();
   }
 
@@ -428,7 +430,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("invalid branch: refs heads master");
   }
 
@@ -438,10 +440,10 @@
     input.branches = ImmutableList.of("master", "refs/meta/config");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.branches).containsExactly("refs/heads/master", "refs/meta/config");
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .containsExactly("refs/heads/master", "refs/meta/config");
   }
 
@@ -582,6 +584,65 @@
   }
 
   @Test
+  public void setCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresIfListOfFilesDidNotChange)
+        .isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresIfListOfFilesDidNotChange = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
+
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresIfListOfFilesDidNotChange)
+        .isTrue();
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType("foo", lt -> lt.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresIfListOfFilesDidNotChange)
+        .isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresIfListOfFilesDidNotChange = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
+
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresIfListOfFilesDidNotChange)
+        .isNull();
+  }
+
+  @Test
   public void setCopyAllScoresIfNoChange() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     try (ProjectConfigUpdate u = updateProject(project)) {
@@ -866,12 +927,12 @@
     LabelDefinitionInfo updatedLabel =
         gApi.projects()
             .name(allProjects.get())
-            .label("Code-Review")
+            .label(LabelId.CODE_REVIEW)
             .update(new LabelDefinitionInput());
     LabelAssert.assertCodeReviewLabel(updatedLabel);
 
     LabelAssert.assertCodeReviewLabel(
-        gApi.projects().name(allProjects.get()).label("Code-Review").get());
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get());
 
     assertThat(projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG))
         .isEqualTo(refsMetaConfigHead);
@@ -881,7 +942,7 @@
   public void defaultCommitMessage() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.function = LabelFunction.NO_OP.getFunctionName();
-    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Update label");
@@ -892,7 +953,7 @@
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.function = LabelFunction.NO_OP.getFunctionName();
     input.commitMessage = "Set NoOp function";
-    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo(input.commitMessage);
@@ -903,7 +964,7 @@
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.function = LabelFunction.NO_OP.getFunctionName();
     input.commitMessage = " Set NoOp function ";
-    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Set NoOp function");
diff --git a/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java b/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java
index 1e33c69..df84fd7 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java
@@ -44,7 +44,7 @@
 
   @Test
   public void userWithDirectMembershipInServiceUserIsAServiceUser() throws Exception {
-    TestAccount user = accountCreator.create(null, "Service Users");
+    TestAccount user = accountCreator.create(null, ServiceUserClassifier.SERVICE_USERS);
     assertThat(serviceUserClassifier.isServiceUser(user.id())).isTrue();
   }
 
@@ -91,7 +91,7 @@
 
   private AccountGroup.UUID serviceUsersUUID() {
     return groupCache
-        .get(AccountGroup.nameKey("Service Users"))
+        .get(AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS))
         .orElseThrow(() -> new IllegalStateException("unable to find 'Service Users'"))
         .getGroupUUID();
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/BUILD b/javatests/com/google/gerrit/acceptance/server/change/BUILD
index 500ab06..19ca946 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/change/BUILD
@@ -5,7 +5,19 @@
     group = "server_change",
     labels = ["server"],
     deps = [
+        ":util",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/util/time",
     ],
 )
+
+java_library(
+    name = "util",
+    srcs = ["CommentsUtil.java"],
+    deps = [
+        "//java/com/google/gerrit/acceptance:lib",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "@guava//jar",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
new file mode 100644
index 0000000..9bd8e9c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
@@ -0,0 +1,525 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
+import static com.google.gerrit.entities.Patch.MERGE_LIST;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.MoreCollectors;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class CommentContextIT extends AbstractDaemonTest {
+  /** The commit message of a single commit. */
+  private static final String SUBJECT =
+      String.join(
+          "\n",
+          "Commit Header",
+          "",
+          "This commit is doing something extremely important",
+          "",
+          "Footer: value");
+
+  private static final String FILE_CONTENT =
+      String.join("\n", "Line 1 of file", "", "Line 3 of file", "", "", "Line 6 of file");
+  private static final ObjectId dummyCommit =
+      ObjectId.fromString("93e2901bc0b4719ef6081ee6353b49c9cdd97614");
+
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Before
+  public void setup() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+  }
+
+  @Test
+  public void commentContextForGitSubmoduleFiles() throws Exception {
+    String submodulePath = "submodule_path";
+
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo).addGitSubmodule(submodulePath, dummyCommit);
+    PushOneCommit.Result pushResult = push.to("refs/for/master");
+    String changeId = pushResult.getChangeId();
+    CommentInput comment =
+        CommentsUtil.newComment(submodulePath, Side.REVISION, 1, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, pushResult.getCommit().name(), comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).path).isEqualTo(submodulePath);
+    assertThat(comments.get(0).contextLines)
+        .isEqualTo(createContextLines("1", "Subproject commit " + dummyCommit.getName()));
+  }
+
+  @Test
+  public void commentContextForCommitMessageForLineComment() throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = CommentsUtil.newComment(COMMIT_MSG, Side.REVISION, 7, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+
+    // The first few lines of the commit message are the headers, e.g.
+    // Parent: ...
+    // Author: ...
+    // AuthorDate: ...
+    // etc...
+    assertThat(comments.get(0).contextLines)
+        .containsExactlyElementsIn(createContextLines("7", "Commit Header"));
+  }
+
+  @Test
+  public void commentContextForMergeList() throws Exception {
+    PushOneCommit.Result result = createMergeCommitChange("refs/for/master");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = CommentsUtil.newComment(MERGE_LIST, Side.REVISION, 1, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).contextLines)
+        .containsExactlyElementsIn(createContextLines("1", "Merge List:"));
+  }
+
+  @Test
+  public void commentContextForCommitMessageForRangeComment() throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment =
+        CommentsUtil.newComment(
+            COMMIT_MSG, Side.REVISION, createCommentRange(7, 9), "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+
+    // The first few lines of the commit message are the headers, e.g.
+    // Parent: ...
+    // Author: ...
+    // AuthorDate: ...
+    // etc...
+    assertThat(comments.get(0).contextLines)
+        .containsExactlyElementsIn(
+            createContextLines(
+                "7",
+                "Commit Header",
+                "8",
+                "",
+                "9",
+                "This commit is doing something extremely important"));
+  }
+
+  @Test
+  public void commentContextForCommitMessageInvalidLine() throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment =
+        CommentsUtil.newComment(COMMIT_MSG, Side.REVISION, 100, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).contextLines).isEmpty();
+  }
+
+  @Test
+  public void listChangeCommentsWithContextEnabled() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+
+    ImmutableList.Builder<String> content = ImmutableList.builder();
+    for (int i = 1; i <= 10; i++) {
+      content.add("line_" + i);
+    }
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                FILE_NAME,
+                content.build().stream().collect(Collectors.joining("\n")),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    CommentsUtil.addCommentOnLine(gApi, r2, "nit: please fix", 1);
+    CommentsUtil.addCommentOnRange(gApi, r2, "looks good", createCommentRange(2, 5));
+
+    List<CommentInfo> comments =
+        gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(2);
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("nit: please fix"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(createContextLines("1", "line_1"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(
+            createContextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
+  }
+
+  @Test
+  public void listChangeDraftsWithContextEnabled() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                FILE_NAME,
+                "line_1\nline_2\nline_3",
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    DraftInput in = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 2, "comment 1");
+    gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).createDraft(in);
+
+    // Test the getAsList interface
+    List<CommentInfo> comments =
+        gApi.changes().id(r2.getChangeId()).draftsRequest().withContext(true).getAsList();
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).message).isEqualTo("comment 1");
+    assertThat(comments.get(0).contextLines)
+        .containsExactlyElementsIn(createContextLines("2", "line_2"));
+
+    // Also test the get interface
+    Map<String, List<CommentInfo>> commentsMap =
+        gApi.changes().id(r2.getChangeId()).draftsRequest().withContext(true).get();
+    assertThat(commentsMap).hasSize(1);
+    assertThat(commentsMap.values().iterator().next()).hasSize(1);
+    CommentInfo onlyComment = commentsMap.values().iterator().next().get(0);
+    assertThat(onlyComment.message).isEqualTo("comment 1");
+    assertThat(onlyComment.contextLines)
+        .containsExactlyElementsIn(createContextLines("2", "line_2"));
+  }
+
+  @Test
+  public void commentContextForCommentsOnDifferentPatchsets() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+
+    ImmutableList.Builder<String> content = ImmutableList.builder();
+    for (int i = 1; i <= 10; i++) {
+      content.add("line_" + i);
+    }
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                FILE_NAME,
+                String.join("\n", content.build()),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    PushOneCommit.Result r3 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                FILE_NAME,
+                content.build().stream().collect(Collectors.joining("\n")),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    CommentsUtil.addCommentOnLine(gApi, r2, "r2: please fix", 1);
+    CommentsUtil.addCommentOnRange(gApi, r2, "r2: looks good", createCommentRange(2, 3));
+    CommentsUtil.addCommentOnLine(gApi, r3, "r3: please fix", 6);
+    CommentsUtil.addCommentOnRange(gApi, r3, "r3: looks good", createCommentRange(7, 8));
+
+    List<CommentInfo> comments =
+        gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(4);
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r2: please fix"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(createContextLines("1", "line_1"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r2: looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(createContextLines("2", "line_2", "3", "line_3"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r3: please fix"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(createContextLines("6", "line_6"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r3: looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(createContextLines("7", "line_7", "8", "line_8"));
+  }
+
+  @Test
+  public void commentContextIsEmptyForPatchsetLevelComments() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment =
+        CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).contextLines).isEmpty();
+  }
+
+  @Test
+  public void commentContextWithZeroPadding() throws Exception {
+    String changeId = createChangeWithComment(3, 4);
+    assertContextLines(changeId, /* contextPadding= */ 0, ImmutableList.of(3, 4));
+  }
+
+  @Test
+  public void commentContextWithSmallPadding() throws Exception {
+    String changeId = createChangeWithComment(3, 4);
+    assertContextLines(changeId, /* contextPadding= */ 1, ImmutableList.of(2, 3, 4, 5));
+  }
+
+  @Test
+  public void commentContextWithSmallPaddingAtTheBeginningOfFile() throws Exception {
+    String changeId = createChangeWithComment(1, 2);
+    assertContextLines(changeId, /* contextPadding= */ 2, ImmutableList.of(1, 2, 3, 4));
+  }
+
+  @Test
+  public void commentContextWithPaddingLargerThanFileSize() throws Exception {
+    String changeId = createChangeWithComment(3, 3);
+    assertContextLines(
+        changeId,
+        /* contextPadding= */ 20,
+        ImmutableList.of(1, 2, 3, 4, 5, 6)); // file only contains six lines.
+  }
+
+  @Test
+  public void commentContextWithLargePaddingReturnsAdjustedMaximumPadding() throws Exception {
+    String changeId = createChangeWithCommentLarge(250, 250);
+    assertContextLines(
+        changeId,
+        /* contextPadding= */ 300,
+        IntStream.range(200, 301).boxed().collect(ImmutableList.toImmutableList()));
+  }
+
+  @Test
+  public void commentContextReturnsCorrectContentTypeForCommitMessage() throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = CommentsUtil.newComment(COMMIT_MSG, Side.REVISION, 7, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).path).isEqualTo(COMMIT_MSG);
+    assertThat(comments.get(0).sourceContentType)
+        .isEqualTo(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE);
+  }
+
+  @Test
+  public void commentContextReturnsCorrectContentType_Java() throws Exception {
+    String javaContent =
+        "public class Main {\n"
+            + " public static void main(String[]args){\n"
+            + " if(args==null){\n"
+            + " System.err.println(\"Something\");\n"
+            + " }\n"
+            + " }\n"
+            + " }";
+    String fileName = "src.java";
+    String changeId = createChangeWithContent(fileName, javaContent, /* line= */ 4);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).path).isEqualTo(fileName);
+    assertThat(comments.get(0).contextLines)
+        .isEqualTo(createContextLines("4", " System.err.println(\"Something\");"));
+    assertThat(comments.get(0).sourceContentType).isEqualTo("text/x-java");
+  }
+
+  @Test
+  public void commentContextReturnsCorrectContentType_Cpp() throws Exception {
+    String cppContent =
+        "#include <iostream>\n"
+            + "\n"
+            + "int main() {\n"
+            + "    std::cout << \"Hello World!\";\n"
+            + "    return 0;\n"
+            + "}";
+    String fileName = "src.cpp";
+    String changeId = createChangeWithContent(fileName, cppContent, /* line= */ 4);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).path).isEqualTo(fileName);
+    assertThat(comments.get(0).contextLines)
+        .isEqualTo(createContextLines("4", "    std::cout << \"Hello World!\";"));
+    assertThat(comments.get(0).sourceContentType).isEqualTo("text/x-c++src");
+  }
+
+  private String createChangeWithContent(String fileName, String fileContent, int line)
+      throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, fileName, fileContent, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = CommentsUtil.newComment(fileName, Side.REVISION, line, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+    return changeId;
+  }
+
+  private String createChangeWithComment(int startLine, int endLine) throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    Comment.Range commentRange = createCommentRange(startLine, endLine);
+    CommentInput comment =
+        CommentsUtil.newComment(FILE_NAME, Side.REVISION, commentRange, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+    return changeId;
+  }
+
+  private String createChangeWithCommentLarge(int startLine, int endLine) throws Exception {
+    StringBuilder largeContent = new StringBuilder();
+    for (int i = 0; i < 1000; i++) {
+      largeContent.append("line " + i + "\n");
+    }
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, largeContent.toString(), "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    Comment.Range commentRange = createCommentRange(startLine, endLine);
+    CommentInput comment =
+        CommentsUtil.newComment(FILE_NAME, Side.REVISION, commentRange, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+    return changeId;
+  }
+
+  private void assertContextLines(
+      String changeId, int contextPadding, ImmutableList<Integer> expectedLines) throws Exception {
+    List<CommentInfo> comments =
+        gApi.changes()
+            .id(changeId)
+            .commentsRequest()
+            .withContext(true)
+            .contextPadding(contextPadding)
+            .getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(
+            comments.get(0).contextLines.stream()
+                .map(c -> c.lineNumber)
+                .collect(Collectors.toList()))
+        .containsExactlyElementsIn(expectedLines);
+  }
+
+  private Comment.Range createCommentRange(int startLine, int endLine) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.endLine = endLine;
+    return range;
+  }
+
+  private List<ContextLineInfo> createContextLines(String... args) {
+    List<ContextLineInfo> result = new ArrayList<>();
+    for (int i = 0; i < args.length; i += 2) {
+      int lineNbr = Integer.parseInt(args[i]);
+      String contextLine = args[i + 1];
+      ContextLineInfo info = new ContextLineInfo(lineNbr, contextLine);
+      result.add(info);
+    }
+    return result;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index b4dd4b3..b29c031 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -23,14 +23,11 @@
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
-import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.collect.MoreCollectors;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -54,7 +51,6 @@
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -72,7 +68,6 @@
 import com.google.inject.Provider;
 import java.sql.Timestamp;
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -80,7 +75,6 @@
 import java.util.function.Function;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
-import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -124,7 +118,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       String path = "file1";
-      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
+      DraftInput comment = CommentsUtil.newDraft(path, Side.REVISION, line, "comment 1");
       addDraft(changeId, revId, comment);
       Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
       assertThat(result).hasSize(1);
@@ -145,10 +139,10 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       String path = "file1";
-      DraftInput c1 = newDraft(path, Side.REVISION, line, "ps-1");
-      DraftInput c2 = newDraft(path, Side.PARENT, line, "auto-merge of ps-1");
-      DraftInput c3 = newDraftOnParent(path, 1, line, "parent-1 of ps-1");
-      DraftInput c4 = newDraftOnParent(path, 2, line, "parent-2 of ps-1");
+      DraftInput c1 = CommentsUtil.newDraft(path, Side.REVISION, line, "ps-1");
+      DraftInput c2 = CommentsUtil.newDraft(path, Side.PARENT, line, "auto-merge of ps-1");
+      DraftInput c3 = CommentsUtil.newDraftOnParent(path, 1, line, "parent-1 of ps-1");
+      DraftInput c4 = CommentsUtil.newDraftOnParent(path, 2, line, "parent-2 of ps-1");
       addDraft(changeId, revId, c1);
       addDraft(changeId, revId, c2);
       addDraft(changeId, revId, c3);
@@ -174,7 +168,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
+      CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1", false);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
       revision(r).review(input);
@@ -193,8 +187,9 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
-    addComments(changeId, ps1, comment);
+    CommentInput comment =
+        CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
 
     Map<String, List<CommentInfo>> results = getPublishedComments(changeId, ps1);
     assertThatMap(results).keys().containsExactly(PATCHSET_LEVEL);
@@ -207,8 +202,9 @@
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
     String commentMessage = "to be deleted";
-    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, commentMessage);
-    addComments(changeId, revId, comment);
+    CommentInput comment =
+        CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, commentMessage);
+    CommentsUtil.addComments(gApi, changeId, revId, comment);
 
     Map<String, List<CommentInfo>> results = getPublishedComments(changeId, revId);
     CommentInfo oldComment = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL));
@@ -227,8 +223,9 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
-    addComments(changeId, ps1, comment);
+    CommentInput comment =
+        CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
 
     String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
     assertThat(emailBody).contains("Patchset");
@@ -241,10 +238,11 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput input = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentInput input = CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     input.line = 1;
     BadRequestException ex =
-        assertThrows(BadRequestException.class, () -> addComments(changeId, ps1, input));
+        assertThrows(
+            BadRequestException.class, () -> CommentsUtil.addComments(gApi, changeId, ps1, input));
     assertThat(ex.getMessage()).contains("line");
   }
 
@@ -254,10 +252,11 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput input = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentInput input = CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     input.range = createLineRange(1, 3);
     BadRequestException ex =
-        assertThrows(BadRequestException.class, () -> addComments(changeId, ps1, input));
+        assertThrows(
+            BadRequestException.class, () -> CommentsUtil.addComments(gApi, changeId, ps1, input));
     assertThat(ex.getMessage()).contains("range");
   }
 
@@ -267,10 +266,11 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput input = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentInput input = CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     input.side = Side.REVISION;
     BadRequestException ex =
-        assertThrows(BadRequestException.class, () -> addComments(changeId, ps1, input));
+        assertThrows(
+            BadRequestException.class, () -> CommentsUtil.addComments(gApi, changeId, ps1, input));
     assertThat(ex.getMessage()).contains("side");
   }
 
@@ -279,7 +279,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     addDraft(changeId, revId, comment);
     Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
     assertThatMap(results).keys().containsExactly(PATCHSET_LEVEL);
@@ -290,7 +290,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput draft = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment 1");
+    DraftInput draft = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment 1");
     CommentInfo returned = addDraft(changeId, revId, draft);
     deleteDraft(changeId, revId, returned.id);
     Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
@@ -302,7 +302,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     comment.line = 1;
     BadRequestException ex =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
@@ -314,7 +314,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     comment.range = createLineRange(1, 3);
     BadRequestException ex =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
@@ -326,7 +326,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     comment.side = Side.REVISION;
     BadRequestException ex =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
@@ -338,10 +338,10 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     addDraft(changeId, revId, comment);
     Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
-    DraftInput update = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput update = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     update.id = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL)).id;
     update.line = 1;
     BadRequestException ex =
@@ -355,10 +355,10 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     addDraft(changeId, revId, comment);
     Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
-    DraftInput update = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput update = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     update.id = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL)).id;
     update.range = createLineRange(1, 3);
     BadRequestException ex =
@@ -372,10 +372,10 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     addDraft(changeId, revId, comment);
     Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
-    DraftInput update = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput update = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     update.id = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL)).id;
     update.side = Side.REVISION;
     BadRequestException ex =
@@ -395,7 +395,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
+      CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1", false);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
       revision(r).review(input);
@@ -403,7 +403,7 @@
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
 
       input = new ReviewInput();
-      comment = newComment(file, Side.REVISION, line, "comment 1 reply", false);
+      comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1 reply", false);
       comment.inReplyTo = actual.id;
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
@@ -427,7 +427,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", true);
+      CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1", true);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
       revision(r).review(input);
@@ -448,10 +448,11 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput c1 = newComment(file, Side.REVISION, line, "ps-1", false);
-      CommentInput c2 = newComment(file, Side.PARENT, line, "auto-merge of ps-1", false);
-      CommentInput c3 = newCommentOnParent(file, 1, line, "parent-1 of ps-1");
-      CommentInput c4 = newCommentOnParent(file, 2, line, "parent-2 of ps-1");
+      CommentInput c1 = CommentsUtil.newComment(file, Side.REVISION, line, "ps-1", false);
+      CommentInput c2 =
+          CommentsUtil.newComment(file, Side.PARENT, line, "auto-merge of ps-1", false);
+      CommentInput c3 = CommentsUtil.newCommentOnParent(file, 1, line, "parent-1 of ps-1");
+      CommentInput c4 = CommentsUtil.newCommentOnParent(file, 2, line, "parent-2 of ps-1");
       input.comments = new HashMap<>();
       input.comments.put(file, ImmutableList.of(c1, c2, c3, c4));
       revision(r).review(input);
@@ -470,9 +471,9 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput c1 = newComment(file, Side.REVISION, line, "ps-1", false);
-      CommentInput c2 = newCommentOnParent(file, 1, line, "parent-1 of ps-1");
-      CommentInput c3 = newCommentOnParent(file, 2, line, "parent-2 of ps-1");
+      CommentInput c1 = CommentsUtil.newComment(file, Side.REVISION, line, "ps-1", false);
+      CommentInput c2 = CommentsUtil.newCommentOnParent(file, 1, line, "parent-1 of ps-1");
+      CommentInput c3 = CommentsUtil.newCommentOnParent(file, 2, line, "parent-2 of ps-1");
       input.comments = new HashMap<>();
       input.comments.put(file, ImmutableList.of(c1, c2, c3));
       revision(r).review(input);
@@ -489,7 +490,8 @@
   public void postCommentOnCommitMessageOnAutoMerge() throws Exception {
     PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
     ReviewInput input = new ReviewInput();
-    CommentInput c = newComment(Patch.COMMIT_MSG, Side.PARENT, 0, "comment on auto-merge", false);
+    CommentInput c =
+        CommentsUtil.newComment(Patch.COMMIT_MSG, Side.PARENT, 0, "comment on auto-merge", false);
     input.comments = new HashMap<>();
     input.comments.put(Patch.COMMIT_MSG, ImmutableList.of(c));
     BadRequestException thrown =
@@ -508,7 +510,7 @@
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
 
-    DraftInput draft = newDraft(file, Side.REVISION, 0, "comment");
+    DraftInput draft = CommentsUtil.newDraft(file, Side.REVISION, 0, "comment");
     addDraft(changeId, revId, draft);
     Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
     CommentInfo draftInfo = Iterables.getOnlyElement(drafts.get(draft.path));
@@ -516,7 +518,7 @@
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.drafts = DraftHandling.KEEP;
     reviewInput.message = "foo";
-    CommentInput comment = newComment(file, Side.REVISION, 0, "comment", false);
+    CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, 0, "comment", false);
     // Replace the existing draft.
     comment.id = draftInfo.id;
     reviewInput.comments = new HashMap<>();
@@ -545,14 +547,14 @@
 
     String draftRefName = RefNames.refsDraftComments(r1.getChange().getId(), admin.id());
 
-    DraftInput draft = newDraft(file, Side.REVISION, 1, "comment");
+    DraftInput draft = CommentsUtil.newDraft(file, Side.REVISION, 1, "comment");
     addDraft(changeId, "1", draft);
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.drafts = DraftHandling.PUBLISH;
     reviewInput.message = "foo";
     gApi.changes().id(r1.getChangeId()).revision(1).review(reviewInput);
 
-    addDraft(changeId, "2", newDraft(file, Side.REVISION, 2, "comment2"));
+    addDraft(changeId, "2", CommentsUtil.newDraft(file, Side.REVISION, 2, "comment2"));
     reviewInput = new ReviewInput();
     reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
     reviewInput.message = "bar";
@@ -581,7 +583,8 @@
     List<CommentInput> expectedComments = new ArrayList<>();
     for (Integer line : lines) {
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment " + line, false);
+      CommentInput comment =
+          CommentsUtil.newComment(file, Side.REVISION, line, "comment " + line, false);
       expectedComments.add(comment);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
@@ -610,11 +613,11 @@
     String revId = r.getCommit().getName();
     String draftRefName = RefNames.refsDraftComments(r.getChange().getId(), user.id());
 
-    DraftInput comment1 = newDraft("file_1", Side.REVISION, 1, "comment 1");
+    DraftInput comment1 = CommentsUtil.newDraft("file_1", Side.REVISION, 1, "comment 1");
     CommentInfo commentInfo1 = addDraft(changeId, revId, comment1);
     assertThat(getHeadOfDraftCommentsRef(draftRefName).getParentCount()).isEqualTo(0);
 
-    DraftInput comment2 = newDraft("file_2", Side.REVISION, 2, "comment 2");
+    DraftInput comment2 = CommentsUtil.newDraft("file_2", Side.REVISION, 2, "comment 2");
     CommentInfo commentInfo2 = addDraft(changeId, revId, comment2);
     assertThat(getHeadOfDraftCommentsRef(draftRefName).getParentCount()).isEqualTo(0);
 
@@ -639,7 +642,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       String path = "file1";
-      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
+      DraftInput comment = CommentsUtil.newDraft(path, Side.REVISION, line, "comment 1");
       addDraft(changeId, revId, comment);
       Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
@@ -662,7 +665,7 @@
     String parentCommentUuid =
         changeOperations.change(changeId).currentPatchset().newComment().create();
 
-    DraftInput draft = newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
+    DraftInput draft = CommentsUtil.newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
     draft.inReplyTo = parentCommentUuid;
     String createdDraftUuid = addDraft(changeId, draft).id;
     TestHumanComment actual =
@@ -676,7 +679,7 @@
     String parentRobotCommentUuid =
         changeOperations.change(changeId).currentPatchset().newRobotComment().create();
 
-    DraftInput draft = newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
+    DraftInput draft = CommentsUtil.newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
     draft.inReplyTo = parentRobotCommentUuid;
     String createdDraftUuid = addDraft(changeId, draft).id;
     TestHumanComment actual =
@@ -690,9 +693,9 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraft(file, Side.REVISION, 0, "foo");
+    DraftInput comment = CommentsUtil.newDraft(file, Side.REVISION, 0, "foo");
     CommentInfo commentInfo = addDraft(changeId, revId, comment);
-    DraftInput draftInput = newDraft(file, Side.REVISION, 0, "bar");
+    DraftInput draftInput = CommentsUtil.newDraft(file, Side.REVISION, 0, "bar");
     draftInput.id = "anything_but_" + commentInfo.id;
     BadRequestException e =
         assertThrows(
@@ -707,7 +710,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraft(file, Side.REVISION, -666, "foo");
+    DraftInput comment = CommentsUtil.newDraft(file, Side.REVISION, -666, "foo");
     BadRequestException e =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
     assertThat(e).hasMessageThat().contains("line must be >= 0");
@@ -719,7 +722,8 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput draftInput = newDraft(file, Side.REVISION, createLineRange(2, 3), "bar");
+    DraftInput draftInput =
+        CommentsUtil.newDraft(file, Side.REVISION, createLineRange(2, 3), "bar");
     draftInput.line = 666;
     BadRequestException e =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, draftInput));
@@ -731,7 +735,7 @@
   @Test
   public void putDraft_invalidInReplyTo() throws Exception {
     Change.Id changeId = changeOperations.newChange().create();
-    DraftInput draft = newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
+    DraftInput draft = CommentsUtil.newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
     draft.inReplyTo = "invalid";
     BadRequestException exception =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, draft));
@@ -743,10 +747,10 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraft("file_foo", Side.REVISION, 0, "foo");
+    DraftInput comment = CommentsUtil.newDraft("file_foo", Side.REVISION, 0, "foo");
     CommentInfo commentInfo = addDraft(changeId, revId, comment);
     assertThat(getDraftComments(changeId, revId).keySet()).containsExactly("file_foo");
-    DraftInput draftInput = newDraft("file_bar", Side.REVISION, 0, "bar");
+    DraftInput draftInput = CommentsUtil.newDraft("file_bar", Side.REVISION, 0, "bar");
     updateDraft(changeId, revId, draftInput, commentInfo.id);
     assertThat(getDraftComments(changeId, revId).keySet()).containsExactly("file_bar");
   }
@@ -754,10 +758,10 @@
   @Test
   public void putDraft_updateInvalidInReplyTo() throws Exception {
     Change.Id changeId = changeOperations.newChange().create();
-    DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    DraftInput originalDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "foo");
     CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
 
-    DraftInput updatedDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    DraftInput updatedDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "bar");
     updatedDraftInput.inReplyTo = "invalid";
     BadRequestException exception =
         assertThrows(
@@ -771,10 +775,10 @@
     Change.Id changeId = changeOperations.newChange().create();
     String parentCommentUuid =
         changeOperations.change(changeId).currentPatchset().newComment().create();
-    DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    DraftInput originalDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "foo");
     CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
 
-    DraftInput updateDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    DraftInput updateDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "bar");
     updateDraftInput.inReplyTo = parentCommentUuid;
     updateDraft(changeId, updateDraftInput, originalDraft.id);
     assertThat(changeOperations.change(changeId).draftComment(originalDraft.id).get().parentUuid())
@@ -786,10 +790,10 @@
     Change.Id changeId = changeOperations.newChange().create();
     String parentRobotCommentUuid =
         changeOperations.change(changeId).currentPatchset().newRobotComment().create();
-    DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    DraftInput originalDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "foo");
     CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
 
-    DraftInput updateDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    DraftInput updateDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "bar");
     updateDraftInput.inReplyTo = parentRobotCommentUuid;
     updateDraft(changeId, updateDraftInput, originalDraft.id);
     assertThat(changeOperations.change(changeId).draftComment(originalDraft.id).get().parentUuid())
@@ -799,10 +803,10 @@
   @Test
   public void putDraft_updateTag() throws Exception {
     Change.Id changeId = changeOperations.newChange().create();
-    DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    DraftInput originalDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "foo");
     CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
 
-    DraftInput updateDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    DraftInput updateDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "bar");
     String tag = "täg";
     updateDraftInput.tag = tag;
     updateDraft(changeId, updateDraftInput, originalDraft.id);
@@ -828,7 +832,7 @@
 
     // Each user can only see their own drafts.
     requestScopeOperations.setApiUser(accountId);
-    DraftInput draftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    DraftInput draftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "bar");
     draftInput.message = "Another comment text.";
     gApi.changes()
         .id(changeId.get())
@@ -851,7 +855,7 @@
 
     List<DraftInput> expectedDrafts = new ArrayList<>();
     for (Integer line : lines) {
-      DraftInput comment = newDraft(file, Side.REVISION, line, "comment " + line);
+      DraftInput comment = CommentsUtil.newDraft(file, Side.REVISION, line, "comment " + line);
       expectedDrafts.add(comment);
       addDraft(changeId, revId, comment);
     }
@@ -870,7 +874,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       String path = "file1";
-      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
+      DraftInput comment = CommentsUtil.newDraft(path, Side.REVISION, line, "comment 1");
       CommentInfo returned = addDraft(changeId, revId, comment);
       CommentInfo actual = getDraftComment(changeId, revId, returned.id);
       assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
@@ -884,7 +888,7 @@
       Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
-      DraftInput draft = newDraft("file1", Side.REVISION, line, "comment 1");
+      DraftInput draft = CommentsUtil.newDraft("file1", Side.REVISION, line, "comment 1");
       CommentInfo returned = addDraft(changeId, revId, draft);
       deleteDraft(changeId, revId, returned.id);
       Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
@@ -909,7 +913,7 @@
       Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
 
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
+      CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1", false);
       comment.updated = timestamp;
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
@@ -935,11 +939,11 @@
     PushOneCommit.Result r1 = createChange();
     String changeId = r1.getChangeId();
     String revId = r1.getCommit().getName();
-    addComment(r1, "nit: trailing whitespace");
-    addComment(r1, "nit: trailing whitespace");
+    CommentsUtil.addComment(gApi, r1, "nit: trailing whitespace");
+    CommentsUtil.addComment(gApi, r1, "nit: trailing whitespace");
     Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
     assertThat(result.get(FILE_NAME)).hasSize(2);
-    addComment(r1, "nit: trailing whitespace", true, false, null);
+    CommentsUtil.addComment(gApi, r1, "nit: trailing whitespace", true, false, null);
     result = getPublishedComments(changeId, revId);
     assertThat(result.get(FILE_NAME)).hasSize(2);
 
@@ -949,7 +953,7 @@
             .to("refs/for/master");
     changeId = r2.getChangeId();
     revId = r2.getCommit().getName();
-    addComment(r2, "nit: trailing whitespace", true, false, null);
+    CommentsUtil.addComment(gApi, r2, "nit: trailing whitespace", true, false, null);
     result = getPublishedComments(changeId, revId);
     assertThat(result.get(FILE_NAME)).hasSize(1);
   }
@@ -967,17 +971,17 @@
     addDraft(
         r1.getChangeId(),
         r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "nit: trailing whitespace"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 1, "nit: trailing whitespace"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "typo: content"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 1, "typo: content"));
 
     requestScopeOperations.setApiUser(user.id());
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "+1, please fix"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 1, "+1, please fix"));
 
     requestScopeOperations.setApiUser(admin.id());
     Map<String, List<CommentInfo>> actual = gApi.changes().id(r1.getChangeId()).drafts();
@@ -1018,8 +1022,8 @@
             .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "new cntent", r1.getChangeId())
             .to("refs/for/master");
 
-    addComment(r1, "nit: trailing whitespace");
-    addComment(r2, "typo: content");
+    CommentsUtil.addComment(gApi, r1, "nit: trailing whitespace");
+    CommentsUtil.addComment(gApi, r2, "typo: content");
 
     Map<String, List<CommentInfo>> actual =
         gApi.changes().id(r2.getChangeId()).commentsRequest().get();
@@ -1047,61 +1051,6 @@
   }
 
   @Test
-  public void listChangeCommentsWithContextEnabled() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-
-    ImmutableList.Builder<String> content = ImmutableList.builder();
-    for (int i = 1; i <= 10; i++) {
-      content.add("line_" + i);
-    }
-
-    PushOneCommit.Result r2 =
-        pushFactory
-            .create(
-                admin.newIdent(),
-                testRepo,
-                SUBJECT,
-                FILE_NAME,
-                content.build().stream().collect(Collectors.joining("\n")),
-                r1.getChangeId())
-            .to("refs/for/master");
-
-    addCommentOnLine(r2, "nit: please fix", 1);
-    addCommentOnRange(r2, "looks good", commentRangeInLines(2, 5));
-
-    List<CommentInfo> comments =
-        gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
-
-    assertThat(comments).hasSize(2);
-
-    assertThat(
-            comments.stream()
-                .filter(c -> c.message.equals("nit: please fix"))
-                .collect(MoreCollectors.onlyElement())
-                .contextLines)
-        .containsExactlyElementsIn(contextLines("1", "line_1"));
-
-    assertThat(
-            comments.stream()
-                .filter(c -> c.message.equals("looks good"))
-                .collect(MoreCollectors.onlyElement())
-                .contextLines)
-        .containsExactlyElementsIn(
-            contextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
-  }
-
-  private List<ContextLineInfo> contextLines(String... args) {
-    List<ContextLineInfo> result = new ArrayList<>();
-    for (int i = 0; i < args.length; i += 2) {
-      int lineNbr = Integer.parseInt(args[i]);
-      String contextLine = args[i + 1];
-      ContextLineInfo info = new ContextLineInfo(lineNbr, contextLine);
-      result.add(info);
-    }
-    return result;
-  }
-
-  @Test
   public void listChangeCommentsAnonymousDoesNotRequireAuth() throws Exception {
     PushOneCommit.Result r1 = createChange();
 
@@ -1110,8 +1059,8 @@
             .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "new cntent", r1.getChangeId())
             .to("refs/for/master");
 
-    addComment(r1, "nit: trailing whitespace");
-    addComment(r2, "typo: content");
+    CommentsUtil.addComment(gApi, r1, "nit: trailing whitespace");
+    CommentsUtil.addComment(gApi, r2, "typo: content");
 
     List<CommentInfo> comments = gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList();
     assertThat(comments.stream().map(c -> c.message).collect(toList()))
@@ -1129,7 +1078,7 @@
       PushOneCommit.Result r = createChange();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
-      DraftInput comment = newDraft("file1", Side.REVISION, line, "comment 1");
+      DraftInput comment = CommentsUtil.newDraft("file1", Side.REVISION, line, "comment 1");
       addDraft(changeId, revId, comment);
       assertThat(gApi.changes().query("change:" + changeId + " has:draft").get()).hasSize(1);
     }
@@ -1163,39 +1112,42 @@
     addDraft(
         r1.getChangeId(),
         r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "Is it that bad?"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "Is it that bad?"));
     addDraft(
         r1.getChangeId(),
         r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, createLineRange(0, 7), "what happened to this?"));
+        CommentsUtil.newDraft(
+            FILE_NAME, Side.PARENT, createLineRange(0, 7), "what happened to this?"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 15), "better now"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 15), "better now"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 2, "typo: content"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 2, "typo: content"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, 1, "comment 1 on base"));
+        CommentsUtil.newDraft(FILE_NAME, Side.PARENT, 1, "comment 1 on base"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, 2, "comment 2 on base"));
+        CommentsUtil.newDraft(FILE_NAME, Side.PARENT, 2, "comment 2 on base"));
 
     PushOneCommit.Result other = createChange();
     // Drafts on other changes aren't returned.
     addDraft(
         other.getChangeId(),
         other.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "unrelated comment"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 1, "unrelated comment"));
 
     requestScopeOperations.setApiUser(admin.id());
     // Drafts by other users aren't returned.
     addDraft(
-        r2.getChangeId(), r2.getCommit().getName(), newDraft(FILE_NAME, Side.REVISION, 2, "oops"));
+        r2.getChangeId(),
+        r2.getCommit().getName(),
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 2, "oops"));
     requestScopeOperations.setApiUser(user.id());
 
     ReviewInput reviewInput = new ReviewInput();
@@ -1325,7 +1277,7 @@
     pub.line = 1;
     pub.message = "published comment";
     pub.path = FILE_NAME;
-    ReviewInput rin = newInput(pub);
+    ReviewInput rin = CommentsUtil.newInput(pub);
     rin.tag = "tag1";
     gApi.changes().id(r.getChangeId()).current().review(rin);
 
@@ -1349,7 +1301,7 @@
   public void draftCommentsWithTagPublishPatchset() throws Exception {
     PushOneCommit.Result result = createChange();
 
-    DraftInput draft = newDraft(FILE_NAME, Side.REVISION, 2, "draft");
+    DraftInput draft = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 2, "draft");
     draft.tag = "old_tag";
     addDraft(result.getChangeId(), result.getCommit().name(), draft);
 
@@ -1369,7 +1321,7 @@
   public void draftCommentsWithTagPublishAllRevisions() throws Exception {
     PushOneCommit.Result result = createChange();
 
-    DraftInput draft = newDraft(FILE_NAME, Side.REVISION, 2, "draft");
+    DraftInput draft = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 2, "draft");
     draft.tag = "old_tag";
     addDraft(result.getChangeId(), result.getCommit().name(), draft);
 
@@ -1395,30 +1347,32 @@
     // PS1 has three comments in three different threads, PS2 has one comment in one thread.
     PushOneCommit.Result result = createChange("change 1", FILE_NAME, "content 1");
     String changeId1 = result.getChangeId();
-    addComment(result, "comment 1", false, true, null);
-    addComment(result, "comment 2", false, null, null);
-    addComment(result, "comment 3", false, false, null);
+    CommentsUtil.addComment(gApi, result, "comment 1", false, true, null);
+    CommentsUtil.addComment(gApi, result, "comment 2", false, null, null);
+    CommentsUtil.addComment(gApi, result, "comment 3", false, false, null);
     PushOneCommit.Result result2 = amendChange(changeId1);
-    addComment(result2, "comment4", false, true, null);
+    CommentsUtil.addComment(gApi, result2, "comment4", false, true, null);
 
     // Change2 has two comments in one thread, the first is unresolved and the second is resolved.
     result = createChange("change 2", FILE_NAME, "content 2");
     String changeId2 = result.getChangeId();
-    addComment(result, "comment 1", false, true, null);
+    CommentsUtil.addComment(gApi, result, "comment 1", false, true, null);
     Map<String, List<CommentInfo>> comments =
         getPublishedComments(changeId2, result.getCommit().name());
     assertThat(comments).hasSize(1);
     assertThat(comments.get(FILE_NAME)).hasSize(1);
-    addComment(result, "comment 2", false, false, comments.get(FILE_NAME).get(0).id);
+    CommentsUtil.addComment(
+        gApi, result, "comment 2", false, false, comments.get(FILE_NAME).get(0).id);
 
     // Change3 has two comments in one thread, the first is resolved, the second is unresolved.
     result = createChange("change 3", FILE_NAME, "content 3");
     String changeId3 = result.getChangeId();
-    addComment(result, "comment 1", false, false, null);
+    CommentsUtil.addComment(gApi, result, "comment 1", false, false, null);
     comments = getPublishedComments(result.getChangeId(), result.getCommit().name());
     assertThat(comments).hasSize(1);
     assertThat(comments.get(FILE_NAME)).hasSize(1);
-    addComment(result, "comment 2", false, true, comments.get(FILE_NAME).get(0).id);
+    CommentsUtil.addComment(
+        gApi, result, "comment 2", false, true, comments.get(FILE_NAME).get(0).id);
 
     try (AutoCloseable ignored = disableNoteDb()) {
       ChangeInfo changeInfo1 = Iterables.getOnlyElement(query(changeId1));
@@ -1436,7 +1390,7 @@
   @Test
   public void deleteCommentCannotBeAppliedByUser() throws Exception {
     PushOneCommit.Result result = createChange();
-    CommentInput targetComment = addComment(result.getChangeId());
+    CommentInput targetComment = CommentsUtil.addComment(gApi, result.getChangeId());
 
     Map<String, List<CommentInfo>> commentsMap =
         getPublishedComments(result.getChangeId(), result.getCommit().name());
@@ -1467,29 +1421,29 @@
     String ps1 = result1.getCommit().name();
 
     // 2nd commit: Add (c1) to PS1.
-    CommentInput c1 = newComment("a.txt", "comment 1");
-    addComments(changeId, ps1, c1);
+    CommentInput c1 = CommentsUtil.newComment("a.txt", "comment 1");
+    CommentsUtil.addComments(gApi, changeId, ps1, c1);
 
     // 3rd commit: Add (c2, c3) to PS1.
-    CommentInput c2 = newComment("a.txt", "comment 2");
-    CommentInput c3 = newComment("a.txt", "comment 3");
-    addComments(changeId, ps1, c2, c3);
+    CommentInput c2 = CommentsUtil.newComment("a.txt", "comment 2");
+    CommentInput c3 = CommentsUtil.newComment("a.txt", "comment 3");
+    CommentsUtil.addComments(gApi, changeId, ps1, c2, c3);
 
     // 4th commit: Add (c4) to PS1.
-    CommentInput c4 = newComment("a.txt", "comment 4");
-    addComments(changeId, ps1, c4);
+    CommentInput c4 = CommentsUtil.newComment("a.txt", "comment 4");
+    CommentsUtil.addComments(gApi, changeId, ps1, c4);
 
     // 5th commit: Create PS2.
     PushOneCommit.Result result2 = amendChange(changeId, "refs/for/master", "b.txt", "b");
     String ps2 = result2.getCommit().name();
 
     // 6th commit: Add (c5) to PS1.
-    CommentInput c5 = newComment("a.txt", "comment 5");
-    addComments(changeId, ps1, c5);
+    CommentInput c5 = CommentsUtil.newComment("a.txt", "comment 5");
+    CommentsUtil.addComments(gApi, changeId, ps1, c5);
 
     // 7th commit: Add (c6) to PS2.
-    CommentInput c6 = newComment("b.txt", "comment 6");
-    addComments(changeId, ps2, c6);
+    CommentInput c6 = CommentsUtil.newComment("b.txt", "comment 6");
+    CommentsUtil.addComments(gApi, changeId, ps2, c6);
 
     // 8th commit: Create PS3.
     PushOneCommit.Result result3 = amendChange(changeId);
@@ -1500,13 +1454,13 @@
     String ps4 = result4.getCommit().name();
 
     // 10th commit: Add (c7, c8) to PS4.
-    CommentInput c7 = newComment("c.txt", "comment 7");
-    CommentInput c8 = newComment("b.txt", "comment 8");
-    addComments(changeId, ps4, c7, c8);
+    CommentInput c7 = CommentsUtil.newComment("c.txt", "comment 7");
+    CommentInput c8 = CommentsUtil.newComment("b.txt", "comment 8");
+    CommentsUtil.addComments(gApi, changeId, ps4, c7, c8);
 
     // 11th commit: Add (c9) to PS2.
-    CommentInput c9 = newCommentWithOnlyMandatoryFields("b.txt", "comment 9");
-    addComments(changeId, ps2, c9);
+    CommentInput c9 = CommentsUtil.newCommentWithOnlyMandatoryFields("b.txt", "comment 9");
+    CommentsUtil.addComments(gApi, changeId, ps2, c9);
 
     List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(id.get());
     assertThat(commentsBeforeDelete).hasSize(9);
@@ -1549,14 +1503,14 @@
     }
 
     // Make sure that comments can still be added correctly.
-    CommentInput c10 = newComment("a.txt", "comment 10");
-    CommentInput c11 = newComment("b.txt", "comment 11");
-    CommentInput c12 = newComment("a.txt", "comment 12");
-    CommentInput c13 = newComment("c.txt", "comment 13");
-    addComments(changeId, ps1, c10);
-    addComments(changeId, ps2, c11);
-    addComments(changeId, ps3, c12);
-    addComments(changeId, ps4, c13);
+    CommentInput c10 = CommentsUtil.newComment("a.txt", "comment 10");
+    CommentInput c11 = CommentsUtil.newComment("b.txt", "comment 11");
+    CommentInput c12 = CommentsUtil.newComment("a.txt", "comment 12");
+    CommentInput c13 = CommentsUtil.newComment("c.txt", "comment 13");
+    CommentsUtil.addComments(gApi, changeId, ps1, c10);
+    CommentsUtil.addComments(gApi, changeId, ps2, c11);
+    CommentsUtil.addComments(gApi, changeId, ps3, c12);
+    CommentsUtil.addComments(gApi, changeId, ps4, c13);
 
     assertThat(getChangeSortedComments(id.get())).hasSize(13);
     assertThat(getRevisionComments(changeId, ps1)).hasSize(6);
@@ -1572,12 +1526,12 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput c1 = newComment(FILE_NAME, "comment 1");
-    CommentInput c2 = newComment(FILE_NAME, "comment 2");
-    CommentInput c3 = newComment(FILE_NAME, "comment 3");
-    addComments(changeId, ps1, c1);
-    addComments(changeId, ps1, c2);
-    addComments(changeId, ps1, c3);
+    CommentInput c1 = CommentsUtil.newComment(FILE_NAME, "comment 1");
+    CommentInput c2 = CommentsUtil.newComment(FILE_NAME, "comment 2");
+    CommentInput c3 = CommentsUtil.newComment(FILE_NAME, "comment 3");
+    CommentsUtil.addComments(gApi, changeId, ps1, c1);
+    CommentsUtil.addComments(gApi, changeId, ps1, c2);
+    CommentsUtil.addComments(gApi, changeId, ps1, c3);
 
     List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(id.get());
     assertThat(commentsBeforeDelete).hasSize(3);
@@ -1614,10 +1568,10 @@
     String parentRobotCommentUuid =
         changeOperations.change(changeId).currentPatchset().newRobotComment().create();
 
-    CommentInput createdCommentInput = newComment(COMMIT_MSG, "comment reply");
+    CommentInput createdCommentInput = CommentsUtil.newComment(COMMIT_MSG, "comment reply");
     createdCommentInput.inReplyTo = parentRobotCommentUuid;
     createdCommentInput.unresolved = null;
-    addComments(changeId, createdCommentInput);
+    CommentsUtil.addComments(gApi, changeId, createdCommentInput);
 
     CommentInfo resultNewComment =
         Iterables.getOnlyElement(
@@ -1637,9 +1591,9 @@
     String parentCommentUuid =
         changeOperations.change(changeId).currentPatchset().newComment().create();
 
-    CommentInput createdCommentInput = newComment(COMMIT_MSG, "comment reply");
+    CommentInput createdCommentInput = CommentsUtil.newComment(COMMIT_MSG, "comment reply");
     createdCommentInput.inReplyTo = parentCommentUuid;
-    addComments(changeId, createdCommentInput);
+    CommentsUtil.addComments(gApi, changeId, createdCommentInput);
 
     CommentInfo resultNewComment =
         Iterables.getOnlyElement(
@@ -1655,9 +1609,9 @@
     String parentRobotCommentUuid =
         changeOperations.change(changeId).currentPatchset().newRobotComment().create();
 
-    CommentInput createdCommentInput = newComment(COMMIT_MSG, "comment reply");
+    CommentInput createdCommentInput = CommentsUtil.newComment(COMMIT_MSG, "comment reply");
     createdCommentInput.inReplyTo = parentRobotCommentUuid;
-    addComments(changeId, createdCommentInput);
+    CommentsUtil.addComments(gApi, changeId, createdCommentInput);
 
     CommentInfo resultNewComment =
         Iterables.getOnlyElement(
@@ -1671,11 +1625,12 @@
   public void cannotCreateCommentWithInvalidInReplyTo() throws Exception {
     Change.Id changeId = changeOperations.newChange().create();
 
-    CommentInput comment = newComment(COMMIT_MSG, "comment 1 reply");
+    CommentInput comment = CommentsUtil.newComment(COMMIT_MSG, "comment 1 reply");
     comment.inReplyTo = "invalid";
 
     BadRequestException exception =
-        assertThrows(BadRequestException.class, () -> addComments(changeId, comment));
+        assertThrows(
+            BadRequestException.class, () -> CommentsUtil.addComments(gApi, changeId, comment));
     assertThat(exception.getMessage()).contains(String.format("%s not found", comment.inReplyTo));
   }
 
@@ -1685,27 +1640,6 @@
         .collect(toList());
   }
 
-  private CommentInput addComment(String changeId) throws Exception {
-    ReviewInput input = new ReviewInput();
-    CommentInput comment = newComment(FILE_NAME, Side.REVISION, 0, "a message", false);
-    input.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
-    gApi.changes().id(changeId).current().review(input);
-    return comment;
-  }
-
-  private void addComments(Change.Id changeId, CommentInput... commentInputs) throws Exception {
-    ReviewInput input = new ReviewInput();
-    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
-    gApi.changes().id(changeId.get()).current().review(input);
-  }
-
-  private void addComments(String changeId, String revision, CommentInput... commentInputs)
-      throws Exception {
-    ReviewInput input = new ReviewInput();
-    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
-    gApi.changes().id(changeId).revision(revision).review(input);
-  }
-
   /**
    * All the commits, which contain the target comment before, should still contain the comment with
    * the updated message. All the other metas of the commits should be exactly the same.
@@ -1766,64 +1700,6 @@
     return m.matches() ? m.group(1) : msg;
   }
 
-  private ReviewInput newInput(CommentInput c) {
-    ReviewInput in = new ReviewInput();
-    in.comments = new HashMap<>();
-    in.comments.put(c.path, Lists.newArrayList(c));
-    return in;
-  }
-
-  private void addComment(PushOneCommit.Result r, String message) throws Exception {
-    addComment(r, message, false, false, null, null, null);
-  }
-
-  private void addCommentOnLine(PushOneCommit.Result r, String message, int line) throws Exception {
-    addComment(r, message, false, false, null, line, null);
-  }
-
-  private void addCommentOnRange(PushOneCommit.Result r, String message, Comment.Range range)
-      throws Exception {
-    addComment(r, message, false, false, null, null, range);
-  }
-
-  private Comment.Range commentRangeInLines(int startLine, int endLine) {
-    Comment.Range range = new Comment.Range();
-    range.startLine = startLine;
-    range.endLine = endLine;
-    return range;
-  }
-
-  private void addComment(
-      PushOneCommit.Result r,
-      String message,
-      boolean omitDuplicateComments,
-      Boolean unresolved,
-      String inReplyTo)
-      throws Exception {
-    addComment(r, message, omitDuplicateComments, unresolved, inReplyTo, null, null);
-  }
-
-  private void addComment(
-      PushOneCommit.Result r,
-      String message,
-      boolean omitDuplicateComments,
-      Boolean unresolved,
-      String inReplyTo,
-      Integer line,
-      Comment.Range range)
-      throws Exception {
-    CommentInput c = new CommentInput();
-    c.line = line == null ? 1 : line;
-    c.message = message;
-    c.path = FILE_NAME;
-    c.unresolved = unresolved;
-    c.inReplyTo = inReplyTo;
-    c.range = range;
-    ReviewInput in = newInput(c);
-    in.omitDuplicateComments = omitDuplicateComments;
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
-  }
-
   private CommentInfo addDraft(String changeId, String revId, DraftInput in) throws Exception {
     return gApi.changes().id(changeId).revision(revId).createDraft(in).get();
   }
@@ -1876,78 +1752,6 @@
     return gApi.changes().id(changeId).revision(revId).draft(uuid).get();
   }
 
-  private static CommentInput newComment(String file, String message) {
-    return newComment(file, Side.REVISION, 0, message, false);
-  }
-
-  private static CommentInput newCommentWithOnlyMandatoryFields(String path, String message) {
-    CommentInput c = new CommentInput();
-    c.unresolved = false;
-    return populate(c, path, null, null, null, null, message);
-  }
-
-  private static CommentInput newComment(
-      String path, Side side, int line, String message, Boolean unresolved) {
-    CommentInput c = new CommentInput();
-    c.unresolved = unresolved;
-    return populate(c, path, side, null, line, message);
-  }
-
-  private static CommentInput newCommentOnParent(
-      String path, int parent, int line, String message) {
-    CommentInput c = new CommentInput();
-    c.unresolved = false;
-    return populate(c, path, Side.PARENT, parent, line, message);
-  }
-
-  private DraftInput newDraft(String path, Side side, int line, String message) {
-    DraftInput d = new DraftInput();
-    d.unresolved = false;
-    return populate(d, path, side, null, line, message);
-  }
-
-  private DraftInput newDraft(String path, Side side, Comment.Range range, String message) {
-    DraftInput d = new DraftInput();
-    d.unresolved = false;
-    return populate(d, path, side, null, range.startLine, range, message);
-  }
-
-  private DraftInput newDraftOnParent(String path, int parent, int line, String message) {
-    DraftInput d = new DraftInput();
-    d.unresolved = false;
-    return populate(d, path, Side.PARENT, parent, line, message);
-  }
-
-  private DraftInput newDraftWithOnlyMandatoryFields(String path, String message) {
-    DraftInput d = new DraftInput();
-    d.unresolved = false;
-    return populate(d, path, null, null, null, null, message);
-  }
-
-  private static <C extends Comment> C populate(
-      C c,
-      String path,
-      Side side,
-      Integer parent,
-      Integer line,
-      Comment.Range range,
-      String message) {
-    c.path = path;
-    c.side = side;
-    c.parent = parent;
-    c.line = line != null && line != 0 ? line : null;
-    c.message = message;
-    if (range != null) {
-      c.range = range;
-    }
-    return c;
-  }
-
-  private static <C extends Comment> C populate(
-      C c, String path, Side side, Integer parent, int line, String message) {
-    return populate(c, path, side, parent, line, null, message);
-  }
-
   private static Comment.Range createLineRange(int startChar, int endChar) {
     Comment.Range range = new Comment.Range();
     range.startLine = 1;
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
new file mode 100644
index 0000000..c4927f0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
@@ -0,0 +1,193 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.Side;
+import java.util.Arrays;
+import java.util.HashMap;
+
+/**
+ * A utility class for creating {@link CommentInput} objects, publishing comments and creating draft
+ * comments. Used by tests that require dealing with comments.
+ */
+class CommentsUtil {
+  static CommentInput addComment(GerritApi gApi, String changeId) throws Exception {
+    ReviewInput input = new ReviewInput();
+    CommentInput comment = CommentsUtil.newComment(FILE_NAME, Side.REVISION, 0, "a message", false);
+    input.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
+    gApi.changes().id(changeId).current().review(input);
+    return comment;
+  }
+
+  static void addComments(GerritApi gApi, Change.Id changeId, CommentInput... commentInputs)
+      throws Exception {
+    ReviewInput input = new ReviewInput();
+    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
+    gApi.changes().id(changeId.get()).current().review(input);
+  }
+
+  static void addComments(
+      GerritApi gApi, String changeId, String revision, CommentInput... commentInputs)
+      throws Exception {
+    ReviewInput input = new ReviewInput();
+    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
+    gApi.changes().id(changeId).revision(revision).review(input);
+  }
+
+  static CommentInput newComment(String file, String message) {
+    return newComment(file, Side.REVISION, 0, message, false);
+  }
+
+  static CommentInput newCommentWithOnlyMandatoryFields(String path, String message) {
+    CommentInput c = new CommentInput();
+    c.unresolved = false;
+    return populate(c, path, null, null, null, null, message);
+  }
+
+  static CommentInput newComment(
+      String path, Side side, int line, String message, Boolean unresolved) {
+    CommentInput c = new CommentInput();
+    c.unresolved = unresolved;
+    return populate(c, path, side, null, line, message);
+  }
+
+  static CommentInput newComment(
+      String path, Side side, Comment.Range range, String message, Boolean unresolved) {
+    CommentInput c = new CommentInput();
+    c.unresolved = unresolved;
+    return populate(c, path, side, null, null, range, message);
+  }
+
+  static CommentInput newCommentOnParent(String path, int parent, int line, String message) {
+    CommentInput c = new CommentInput();
+    c.unresolved = false;
+    return populate(c, path, Side.PARENT, parent, line, message);
+  }
+
+  static DraftInput newDraft(String path, Side side, int line, String message) {
+    DraftInput d = new DraftInput();
+    d.unresolved = false;
+    return populate(d, path, side, null, line, message);
+  }
+
+  static DraftInput newDraft(String path, Side side, Comment.Range range, String message) {
+    DraftInput d = new DraftInput();
+    d.unresolved = false;
+    return populate(d, path, side, null, range.startLine, range, message);
+  }
+
+  static DraftInput newDraftOnParent(String path, int parent, int line, String message) {
+    DraftInput d = new DraftInput();
+    d.unresolved = false;
+    return populate(d, path, Side.PARENT, parent, line, message);
+  }
+
+  static DraftInput newDraftWithOnlyMandatoryFields(String path, String message) {
+    DraftInput d = new DraftInput();
+    d.unresolved = false;
+    return populate(d, path, null, null, null, null, message);
+  }
+
+  static <C extends Comment> C populate(
+      C c,
+      String path,
+      Side side,
+      Integer parent,
+      Integer line,
+      Comment.Range range,
+      String message) {
+    c.path = path;
+    c.side = side;
+    c.parent = parent;
+    c.line = line != null && line != 0 ? line : null;
+    c.message = message;
+    if (range != null) {
+      c.range = range;
+    }
+    return c;
+  }
+
+  static <C extends Comment> C populate(
+      C c, String path, Side side, Integer parent, int line, String message) {
+    return populate(c, path, side, parent, line, null, message);
+  }
+
+  static ReviewInput newInput(CommentInput c) {
+    ReviewInput in = new ReviewInput();
+    in.comments = new HashMap<>();
+    in.comments.put(c.path, Lists.newArrayList(c));
+    return in;
+  }
+
+  static void addComment(GerritApi gApi, PushOneCommit.Result r, String message) throws Exception {
+    addComment(gApi, r, message, false, false, null, null, null);
+  }
+
+  static void addComment(
+      GerritApi gApi,
+      PushOneCommit.Result r,
+      String message,
+      boolean omitDuplicateComments,
+      Boolean unresolved,
+      String inReplyTo)
+      throws Exception {
+    addComment(gApi, r, message, omitDuplicateComments, unresolved, inReplyTo, null, null);
+  }
+
+  static void addCommentOnLine(GerritApi gApi, PushOneCommit.Result r, String message, int line)
+      throws Exception {
+    addComment(gApi, r, message, false, false, null, line, null);
+  }
+
+  static void addCommentOnRange(
+      GerritApi gApi, PushOneCommit.Result r, String message, Comment.Range range)
+      throws Exception {
+    addComment(gApi, r, message, false, false, null, null, range);
+  }
+
+  static void addComment(
+      GerritApi gApi,
+      PushOneCommit.Result r,
+      String message,
+      boolean omitDuplicateComments,
+      Boolean unresolved,
+      String inReplyTo,
+      Integer line,
+      Comment.Range range)
+      throws Exception {
+    CommentInput c = new CommentInput();
+    c.line = line == null ? 1 : line;
+    c.message = message;
+    c.path = FILE_NAME;
+    c.unresolved = unresolved;
+    c.inReplyTo = inReplyTo;
+    c.range = range;
+    ReviewInput in = CommentsUtil.newInput(c);
+    in.omitDuplicateComments = omitDuplicateComments;
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/BUILD b/javatests/com/google/gerrit/acceptance/server/experiments/BUILD
new file mode 100644
index 0000000..0f01ffa
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_experiments",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
new file mode 100644
index 0000000..09e6dfe
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.experiments;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+/** Tests for {@link ExperimentFeatures} */
+public class ExperimentFeaturesIT extends AbstractDaemonTest {
+
+  @Inject ExperimentFeatures experimentFeatures;
+
+  @Test
+  public void emptyConfig_defaultFeatures_enabled() {
+    for (String defaultFeature : ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES) {
+      assertThat(experimentFeatures.isFeatureEnabled(defaultFeature)).isTrue();
+    }
+
+    assertThat(experimentFeatures.getEnabledExperimentFeatures())
+        .isEqualTo(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {"enabledFeature", "enabledThenDisabledFeature"})
+  @GerritConfig(
+      name = "experiments.disabled",
+      values = {"enabledThenDisabledFeature"})
+  public void configOverride_anyFeatureAllowed() {
+    assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue();
+    assertThat(experimentFeatures.isFeatureEnabled("enabledThenDisabledFeature")).isFalse();
+    assertThat(experimentFeatures.isFeatureEnabled("unknownFeature")).isFalse();
+    ImmutableSet<String> expectedEnabledFeatures =
+        new ImmutableSet.Builder<String>()
+            .addAll(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES)
+            .add("enabledFeature")
+            .build();
+    assertThat(experimentFeatures.getEnabledExperimentFeatures())
+        .isEqualTo(expectedEnabledFeatures);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {"enabledFeature"})
+  @GerritConfig(
+      name = "experiments.disabled",
+      values = {"UiFeature__patchset_comments"})
+  public void configOverride_defaultFeatureDisabled() {
+    assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue();
+    assertThat(
+            experimentFeatures.isFeatureEnabled(
+                ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS))
+        .isFalse();
+    assertThat(experimentFeatures.getEnabledExperimentFeatures()).containsExactly("enabledFeature");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 9b12f29..f267513 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
@@ -89,7 +90,7 @@
         .add(allow(Permission.FORGE_COMMITTER).ref("refs/*").group(REGISTERED_USERS))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .add(allow(Permission.ABANDON).ref("refs/*").group(REGISTERED_USERS))
-        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
         .update();
   }
 
@@ -1470,14 +1471,14 @@
 
   private void deleteVote(StagedChange sc, TestAccount account) throws Exception {
     sender.clear();
-    gApi.changes().id(sc.changeId).reviewer(account.email()).deleteVote("Code-Review");
+    gApi.changes().id(sc.changeId).reviewer(account.email()).deleteVote(LabelId.CODE_REVIEW);
   }
 
   private void deleteVote(StagedChange sc, TestAccount account, NotifyHandling notify)
       throws Exception {
     sender.clear();
     DeleteVoteInput in = new DeleteVoteInput();
-    in.label = "Code-Review";
+    in.label = LabelId.CODE_REVIEW;
     in.notify = notify;
     gApi.changes().id(sc.changeId).reviewer(account.email()).deleteVote(in);
   }
@@ -1714,7 +1715,7 @@
         .sent("newpatchset", sc)
         .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
         .to(sc.reviewer)
-        .to(other)
+        .cc(other)
         .cc(sc.ccer)
         .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
@@ -1730,7 +1731,7 @@
         .sent("newpatchset", sc)
         .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
         .to(sc.reviewer)
-        .to(other)
+        .cc(other)
         .cc(sc.ccer)
         .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/BUILD b/javatests/com/google/gerrit/acceptance/server/permissions/BUILD
new file mode 100644
index 0000000..e89e8d1
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_permissions",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
new file mode 100644
index 0000000..9e4907c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
@@ -0,0 +1,301 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.permissions;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.ExternalUser;
+import com.google.gerrit.server.PropertyMap;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import java.util.Collection;
+import java.util.Set;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests that permission logic used by {@link ExternalUser} works as expected. */
+public class ExternalUserPermissionIT extends AbstractDaemonTest {
+  private static final AccountGroup.UUID EXTERNAL_GROUP =
+      AccountGroup.uuid("company-auth:it-department");
+
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private ExternalUser.Factory externalUserFactory;
+  @Inject private GroupOperations groupOperations;
+
+  @Before
+  public void setUp() {
+    // Allow only read on refs/heads/master by default
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/master").group(ANONYMOUS_USERS))
+        .update();
+  }
+
+  @Override
+  public Module createModule() {
+    /**
+     * Binding a {@link GroupBackend} that pretends a user is part of a group if the external ID
+     * starts with the group UUID.
+     *
+     * <p>Example: Users "company-auth:it-department-1" and "company-auth:it-department-2" are a
+     * member of the group "company-auth:it-department"
+     */
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        DynamicSet.bind(binder(), GroupBackend.class)
+            .toInstance(
+                new GroupBackend() {
+                  @Override
+                  public boolean handles(AccountGroup.UUID uuid) {
+                    return uuid.get().startsWith("company-auth:");
+                  }
+
+                  @Override
+                  public GroupDescription.Basic get(AccountGroup.UUID uuid) {
+                    return new GroupDescription.Basic() {
+                      @Override
+                      public AccountGroup.UUID getGroupUUID() {
+                        return uuid;
+                      }
+
+                      @Override
+                      public String getName() {
+                        return uuid.get();
+                      }
+
+                      @Override
+                      public String getEmailAddress() {
+                        return uuid.get() + "@example.com";
+                      }
+
+                      @Override
+                      public String getUrl() {
+                        return null;
+                      }
+                    };
+                  }
+
+                  @Override
+                  public Collection<GroupReference> suggest(String name, ProjectState project) {
+                    throw new UnsupportedOperationException("not implemented");
+                  }
+
+                  @Override
+                  public GroupMembership membershipsOf(CurrentUser user) {
+                    return new GroupMembership() {
+                      @Override
+                      public boolean contains(AccountGroup.UUID groupId) {
+                        return user.getExternalIdKeys().stream()
+                            .anyMatch(e -> e.get().startsWith(groupId.get()));
+                      }
+
+                      @Override
+                      public boolean containsAnyOf(Iterable<AccountGroup.UUID> groupIds) {
+                        return ImmutableList.copyOf(groupIds).stream().anyMatch(g -> contains(g));
+                      }
+
+                      @Override
+                      public Set<AccountGroup.UUID> intersection(
+                          Iterable<AccountGroup.UUID> groupIds) {
+                        return ImmutableList.copyOf(groupIds).stream()
+                            .filter(g -> contains(g))
+                            .collect(toImmutableSet());
+                      }
+
+                      @Override
+                      public Set<AccountGroup.UUID> getKnownGroups() {
+                        return ImmutableSet.of();
+                      }
+                    };
+                  }
+
+                  @Override
+                  public boolean isVisibleToAll(AccountGroup.UUID uuid) {
+                    return false;
+                  }
+                });
+      }
+    };
+  }
+
+  @Test
+  public void defaultRefFilter_changeVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    ExternalUser user = createUserInGroup("1", "it-department");
+
+    Change.Id changeOnMaster = changeOperations.newChange().project(project).create();
+    Change.Id changeOnRefsMetaConfig =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    // Check that only the change on the default branch is visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)));
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(EXTERNAL_GROUP))
+        .update();
+    // Check that both changes are visible now
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            "refs/meta/config",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)),
+            RefNames.changeMetaRef(changeOnRefsMetaConfig),
+            RefNames.patchSetRef(PatchSet.id(changeOnRefsMetaConfig, 1)));
+  }
+
+  @Test
+  public void defaultRefFilter_refVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    ExternalUser user = createUserInGroup("1", "it-department");
+    // Check that refs/meta/config isn't visible by default
+    assertThat(getVisibleRefNames(user)).containsExactly("HEAD", "refs/heads/master");
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(EXTERNAL_GROUP))
+        .update();
+    // Check that refs/meta/config became visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly("HEAD", "refs/heads/master", "refs/meta/config");
+  }
+
+  @Test
+  public void changeVisibility_changeOnInvisibleBranchNotVisible() throws Exception {
+    // Create a change that is not visible to members of 'externalGroup'
+    Change.Id invisibleChange =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    ExternalUser user = createUserInGroup("1", "it-department");
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                permissionBackend
+                    .user(user)
+                    .change(changeNotesFactory.create(project, invisibleChange))
+                    .check(ChangePermission.READ));
+    assertThat(thrown).hasMessageThat().isEqualTo("read not permitted");
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToAnonymousIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    ExternalUser user = createUserInGroup("1", "it-department");
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToRegisteredUsersIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    ExternalUser user = createUserInGroup("1", "it-department");
+    blockAnonymousRead();
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  @Test
+  public void externalUser_isContainedInternalGroupThatContainsExternalGroup() {
+    AccountGroup.UUID internalGroup =
+        groupOperations.newGroup().addSubgroup(EXTERNAL_GROUP).create();
+    ExternalUser user = createUserInGroup("1", "it-department");
+    assertThat(user.getEffectiveGroups().contains(internalGroup)).isTrue();
+    assertThat(user.getEffectiveGroups().contains(EXTERNAL_GROUP)).isTrue();
+    assertThat(user.getEffectiveGroups().contains(REGISTERED_USERS)).isTrue();
+    assertThat(user.getEffectiveGroups().contains(ANONYMOUS_USERS)).isTrue();
+  }
+
+  @GerritConfig(name = "groups.includeExternalUsersInRegisteredUsersGroup", value = "true")
+  @Test
+  public void externalUser_isContainedInRegisteredUsersIfConfigured() {
+    ExternalUser user = createUserInGroup("1", "it-department");
+    assertThat(user.getEffectiveGroups().contains(REGISTERED_USERS)).isTrue();
+  }
+
+  @GerritConfig(name = "groups.includeExternalUsersInRegisteredUsersGroup", value = "false")
+  @Test
+  public void externalUser_isNotContainedInRegisteredUsersIfNotConfigured() {
+    ExternalUser user = createUserInGroup("1", "it-department");
+    assertThat(user.getEffectiveGroups().contains(REGISTERED_USERS)).isFalse();
+  }
+
+  private ImmutableList<String> getVisibleRefNames(CurrentUser user) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return permissionBackend.user(user).project(project)
+          .filter(
+              repo.getRefDatabase().getRefs(), repo, PermissionBackend.RefFilterOptions.defaults())
+          .stream()
+          .map(Ref::getName)
+          .collect(toImmutableList());
+    }
+  }
+
+  ExternalUser createUserInGroup(String userId, String groupId) {
+    return externalUserFactory.create(
+        ImmutableSet.of(),
+        ImmutableSet.of(ExternalId.Key.parse("company-auth:" + groupId + "-" + userId)),
+        PropertyMap.EMPTY);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
new file mode 100644
index 0000000..d68d681
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
@@ -0,0 +1,184 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.permissions;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.query.change.GroupBackedUser;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests that permission logic used by {@link GroupBackedUser} works as expected. */
+public class GroupBackedUserPermissionIT extends AbstractDaemonTest {
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+
+  private final TestGroupBackend testGroupBackend = new TestGroupBackend();
+  private final AccountGroup.UUID externalGroup = AccountGroup.uuid("testbackend:test");
+
+  @Before
+  public void setUp() {
+    // Allow only read on refs/heads/master by default
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/master").group(ANONYMOUS_USERS))
+        .update();
+  }
+
+  @Override
+  public Module createModule() {
+    /** Binding a {@link TestGroupBackend} to test adding external groups * */
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        DynamicSet.bind(binder(), GroupBackend.class).toInstance(testGroupBackend);
+      }
+    };
+  }
+
+  @Test
+  public void defaultRefFilter_changeVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    Change.Id changeOnMaster = changeOperations.newChange().project(project).create();
+    Change.Id changeOnRefsMetaConfig =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    // Check that only the change on the default branch is visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)));
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(externalGroup))
+        .update();
+    // Check that both changes are visible now
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            "refs/meta/config",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)),
+            RefNames.changeMetaRef(changeOnRefsMetaConfig),
+            RefNames.patchSetRef(PatchSet.id(changeOnRefsMetaConfig, 1)));
+  }
+
+  @Test
+  public void defaultRefFilter_refVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    // Check that refs/meta/config isn't visible by default
+    assertThat(getVisibleRefNames(user)).containsExactly("HEAD", "refs/heads/master");
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(externalGroup))
+        .update();
+    // Check that refs/meta/config became visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly("HEAD", "refs/heads/master", "refs/meta/config");
+  }
+
+  @Test
+  public void changeVisibility_changeOnInvisibleBranchNotVisible() throws Exception {
+    // Create a change that is not visible to members of 'externalGroup'
+    Change.Id invisibleChange =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                permissionBackend
+                    .user(user)
+                    .change(changeNotesFactory.create(project, invisibleChange))
+                    .check(ChangePermission.READ));
+    assertThat(thrown).hasMessageThat().isEqualTo("read not permitted");
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToAnonymousIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToRegisteredUsersIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    blockAnonymousRead();
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  private ImmutableList<String> getVisibleRefNames(CurrentUser user) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return permissionBackend.user(user).project(project)
+          .filter(
+              repo.getRefDatabase().getRefs(), repo, PermissionBackend.RefFilterOptions.defaults())
+          .stream()
+          .map(Ref::getName)
+          .collect(toImmutableList());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
index 127f34b..df668a5 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.AccountGroup;
@@ -65,6 +66,44 @@
   }
 
   @Test
+  @GerritConfig(name = "gerrit.enablePeerIPInReflogRecord", value = "true")
+  public void peerIPIncludedInReflogRecord() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    try (Repository repo = repoManager.openRepository(r.getChange().project())) {
+      File log = new File(repo.getDirectory(), "logs/" + changeMetaRef(id));
+      if (!log.exists()) {
+        log.getParentFile().mkdirs();
+        assertThat(log.createNewFile()).isTrue();
+      }
+
+      gApi.changes().id(id.get()).topic("foo");
+      ReflogEntry last = repo.getReflogReader(changeMetaRef(id)).getLastEntry();
+      assertThat(last.getWho().getEmailAddress())
+          .isEqualTo(admin.username() + "|account-" + admin.id() + "@unknown");
+    }
+  }
+
+  @Test
+  public void emaiIncludedInReflogRecord() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    try (Repository repo = repoManager.openRepository(r.getChange().project())) {
+      File log = new File(repo.getDirectory(), "logs/" + changeMetaRef(id));
+      if (!log.exists()) {
+        log.getParentFile().mkdirs();
+        assertThat(log.createNewFile()).isTrue();
+      }
+
+      gApi.changes().id(id.get()).topic("foo");
+      ReflogEntry last = repo.getReflogReader(changeMetaRef(id)).getLastEntry();
+      assertThat(last.getWho().getEmailAddress()).isEqualTo(admin.email());
+    }
+  }
+
+  @Test
   public void reflogUpdatedBySubmittingChange() throws Exception {
     BranchApi branchApi = gApi.projects().name(project.get()).branch("master");
     List<ReflogEntryInfo> reflog = branchApi.reflog();
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
index d3b40cc..4ce2deb 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
@@ -20,9 +20,10 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
 import com.google.inject.Inject;
 import java.util.Map;
@@ -37,7 +38,7 @@
 
   @Test
   public void blocksWhenUploaderIsOnlyApprover() throws Exception {
-    enableRule("Code-Review", true);
+    enableRule(LabelId.CODE_REVIEW, true);
 
     PushOneCommit.Result r = createChange();
     approve(r.getChangeId());
@@ -50,7 +51,7 @@
     assertThat(result.labels).isNotEmpty();
     assertThat(result.requirements)
         .containsExactly(
-            SubmitRequirement.builder()
+            LegacySubmitRequirement.builder()
                 .setFallbackText("Approval from non-uploader required")
                 .setType("non_uploader_approval")
                 .build());
@@ -58,7 +59,7 @@
 
   @Test
   public void allowsSubmissionWhenChangeHasNonUploaderApproval() throws Exception {
-    enableRule("Code-Review", true);
+    enableRule(LabelId.CODE_REVIEW, true);
 
     // Create change as user
     TestRepository<InMemoryRepository> userTestRepo = cloneProject(project, user);
@@ -74,7 +75,7 @@
 
   @Test
   public void doesNothingByDefault() throws Exception {
-    enableRule("Code-Review", false);
+    enableRule(LabelId.CODE_REVIEW, false);
 
     PushOneCommit.Result r = createChange();
     approve(r.getChangeId());
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
index 6079388..bf8b1f8 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.PrologOptions;
@@ -41,7 +42,7 @@
   public void convertsPrologToSubmitRecord() {
     PrologRuleEvaluator evaluator = makeEvaluator();
 
-    StructureTerm verifiedLabel = makeLabel("Verified", "may");
+    StructureTerm verifiedLabel = makeLabel(LabelId.VERIFIED, "may");
     StructureTerm labels = new StructureTerm("label", verifiedLabel);
 
     List<Term> terms = ImmutableList.of(makeTerm("ok", labels));
@@ -85,12 +86,12 @@
     PrologRuleEvaluator evaluator = makeEvaluator();
 
     SubmitRecord.Label submitRecordLabel1 = new SubmitRecord.Label();
-    submitRecordLabel1.label = "Verified";
+    submitRecordLabel1.label = LabelId.VERIFIED;
     submitRecordLabel1.status = SubmitRecord.Label.Status.REJECT;
     submitRecordLabel1.appliedBy = admin.id();
 
     SubmitRecord.Label submitRecordLabel2 = new SubmitRecord.Label();
-    submitRecordLabel2.label = "Code-Review";
+    submitRecordLabel2.label = LabelId.CODE_REVIEW;
     submitRecordLabel2.status = SubmitRecord.Label.Status.OK;
     submitRecordLabel2.appliedBy = admin.id();
 
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
index 5cbc767..6cb13c5 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -27,7 +28,9 @@
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+import java.util.Arrays;
 import java.util.Collection;
+import java.util.Map;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
@@ -39,7 +42,7 @@
 @NoHttpd
 public class RulesIT extends AbstractDaemonTest {
   private static final String RULE_TEMPLATE =
-      "submit_rule(submit(W)) :- \n" + "%s,\n" + "W = label('OK', ok(user(1000000))).";
+      "submit_rule(submit(W)) :- \n%s,\nW = label('OK', ok(user(1000000))).";
 
   @Inject private ProjectOperations projectOperations;
   @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
@@ -89,12 +92,122 @@
     assertThat(statusForRuleRemoveFile()).isEqualTo(SubmitRecord.Status.OK);
   }
 
+  @Test
+  public void testCommitDelta_pass() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('file1\\.txt')");
+    assertThat(statusForRuleAddFile("file1.txt")).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_fail() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('no such file')");
+    assertThat(statusForRuleAddFile("file1.txt")).isEqualTo(SubmitRecord.Status.RULE_ERROR);
+  }
+
+  @Test
+  public void testCommitDelta_addOwners_pass() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('OWNERS', add, _, _)");
+    assertThat(statusForRuleAddFile("foo/OWNERS")).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_addOwners_fail() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('OWNERS', add, _, _)");
+    assertThat(statusForRuleAddFile("foobar")).isEqualTo(SubmitRecord.Status.RULE_ERROR);
+  }
+
+  @Test
+  public void testCommitDelta_regexp() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*')");
+    assertThat(statusForRuleAddFile("foo/bar")).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_add_provideNewName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*', _, 'foo')");
+    assertThat(statusForRuleAddFile("foo")).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_modify_provideNewName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*', _, 'a.txt')");
+    assertThat(statusForRuleModifyFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_delete_provideNewName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*', _, 'a.txt')");
+    assertThat(statusForRuleRemoveFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_rename_provideOldName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*', _, 'a.txt')");
+    assertThat(statusForRuleRenamedFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_rename_provideNewName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*', _, 'b.txt')");
+    assertThat(statusForRuleRenamedFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_rename_matchOldName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('a\\.txt')");
+    assertThat(statusForRuleRenamedFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_rename_matchNewName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('b\\.txt')");
+    assertThat(statusForRuleRenamedFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void typeError() throws Exception {
+    modifySubmitRules("user(1000000)."); // the trailing '.' triggers a type error
+    assertThat(statusForRuleAddFile("foo")).isEqualTo(SubmitRecord.Status.RULE_ERROR);
+  }
+
   private SubmitRecord.Status statusForRule() throws Exception {
     String oldHead = projectOperations.project(project).getHead("master").name();
-    PushOneCommit.Result result1 =
+    PushOneCommit.Result result =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     testRepo.reset(oldHead);
-    return getStatus(result1);
+    return getStatus(result);
+  }
+
+  private SubmitRecord.Status statusForRuleAddFile(String... filenames) throws Exception {
+    Map<String, String> fileToContentMap =
+        Arrays.stream(filenames).collect(ImmutableMap.toImmutableMap(f -> f, f -> "file content"));
+    String oldHead = projectOperations.project(project).getHead("master").name();
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "subject", fileToContentMap);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+    testRepo.reset(oldHead);
+    return getStatus(result);
+  }
+
+  private SubmitRecord.Status statusForRuleModifyFile() throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+
+    // create a.txt
+    commitBuilder().add(PushOneCommit.FILE_NAME, "Hey, it's me!").message("subject").create();
+    pushHead(testRepo, "refs/heads/master", false);
+
+    PushOneCommit.Result result =
+        pushFactory
+            .create(
+                user.newIdent(),
+                testRepo,
+                "subject",
+                ImmutableMap.of(PushOneCommit.FILE_NAME, "I've changed!"))
+            .rmFile(PushOneCommit.FILE_NAME)
+            .to("refs/for/master");
+    testRepo.reset(oldHead);
+    return getStatus(result);
   }
 
   private SubmitRecord.Status statusForRuleRemoveFile() throws Exception {
@@ -110,15 +223,30 @@
     return getStatus(result);
   }
 
-  private SubmitRecord.Status getStatus(PushOneCommit.Result result1) throws Exception {
-    ChangeData cd = result1.getChange();
+  private SubmitRecord.Status statusForRuleRenamedFile() throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+
+    // create a.txt
+    commitBuilder().add(PushOneCommit.FILE_NAME, "Hey, it's me!").message("subject").create();
+    pushHead(testRepo, "refs/heads/master", false);
+
+    PushOneCommit.Result result =
+        pushFactory
+            .create(user.newIdent(), testRepo, "subject", ImmutableMap.of("b.txt", "Hey, it's me!"))
+            .rmFile(PushOneCommit.FILE_NAME)
+            .to("refs/for/master");
+    testRepo.reset(oldHead);
+    return getStatus(result);
+  }
+
+  private SubmitRecord.Status getStatus(PushOneCommit.Result result) throws Exception {
+    ChangeData cd = result.getChange();
 
     Collection<SubmitRecord> records;
-    try (AutoCloseable changeIndex = disableChangeIndex()) {
-      try (AutoCloseable accountIndex = disableAccountIndex()) {
-        SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
-        records = ruleEvaluator.evaluate(cd);
-      }
+    try (AutoCloseable ignored1 = disableChangeIndex();
+        AutoCloseable ignored2 = disableAccountIndex()) {
+      SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
+      records = ruleEvaluator.evaluate(cd);
     }
 
     assertThat(records).hasSize(1);
diff --git a/javatests/com/google/gerrit/acceptance/server/util/BUILD b/javatests/com/google/gerrit/acceptance/server/util/BUILD
new file mode 100644
index 0000000..ea25784
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/util/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_util",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/util/PluginLogFileIT.java b/javatests/com/google/gerrit/acceptance/server/util/PluginLogFileIT.java
new file mode 100644
index 0000000..06bf1ae
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/util/PluginLogFileIT.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.util;
+
+import static junit.framework.TestCase.assertEquals;
+import static org.junit.Assert.fail;
+
+import com.google.gerrit.acceptance.AbstractPluginLogFileTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.inject.Inject;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class PluginLogFileIT extends AbstractPluginLogFileTest {
+  @Inject private InvocationCounter invocationCounter;
+  private static final int NUMBER_OF_THREADS = 5;
+
+  @Test
+  public void testMultiThreadedPluginLogFile() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", Module.class)) {
+      ExecutorService service = Executors.newFixedThreadPool(NUMBER_OF_THREADS);
+      CountDownLatch latch = new CountDownLatch(NUMBER_OF_THREADS);
+      createChange();
+      for (int i = 0; i < NUMBER_OF_THREADS; i++) {
+        service.execute(
+            () -> {
+              try {
+                adminSshSession.exec("gerrit query --format json status:open --my-plugin--opt");
+                adminSshSession.assertSuccess();
+              } catch (Exception e) {
+                fail(e.getMessage());
+              } finally {
+                latch.countDown();
+              }
+            });
+      }
+      latch.await();
+      assertEquals(1, invocationCounter.getCounter());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
index 01b8eae..5429131 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
@@ -19,6 +19,7 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.project.ProjectState;
 import java.util.Optional;
@@ -85,4 +86,35 @@
     assertThat(projectState).isPresent();
     assertThat(projectState.get().getName()).isEqualTo(newProjectName);
   }
+
+  @Test
+  public void withEmptyBranches() throws Exception {
+    String newProjectName = name("newProject");
+    adminSshSession.exec("gerrit create-project " + newProjectName);
+    adminSshSession.assertSuccess();
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isPresent();
+    assertHead(newProjectName, "refs/heads/master");
+  }
+
+  @Test
+  public void withInitBranches() throws Exception {
+    String newProjectName = name("newProject");
+    adminSshSession.exec("gerrit create-project --branch init-branch " + newProjectName);
+    adminSshSession.assertSuccess();
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isPresent();
+    assertHead(newProjectName, "refs/heads/init-branch");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.defaultBranch", value = "refs/heads/main")
+  public void withEmptyBranches_WhenDefaultBranchIsSet() throws Exception {
+    String newProjectName = name("newProject");
+    adminSshSession.exec("gerrit create-project " + newProjectName);
+    adminSshSession.assertSuccess();
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isPresent();
+    assertHead(newProjectName, "refs/heads/main");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsIT.java b/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsIT.java
new file mode 100644
index 0000000..4efa247
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsIT.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.gerrit.server.query.change.OutputStreamQuery.GSON;
+import static junit.framework.TestCase.assertEquals;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.AbstractDynamicOptionsTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.Module;
+import java.util.List;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class DynamicOptionsIT extends AbstractDynamicOptionsTest {
+
+  @Override
+  public Module createSshModule() {
+    return new AbstractDynamicOptionsTest.PluginOneSshModule();
+  }
+
+  @Test
+  public void testDynamicPluginOptions() throws Exception {
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", AbstractDynamicOptionsTest.PluginTwoModule.class)) {
+      List<String> samples = getSamplesList(adminSshSession.exec("ls-samples"));
+      adminSshSession.assertSuccess();
+      assertEquals(Lists.newArrayList("sample1", "sample2"), samples);
+    }
+  }
+
+  protected List<String> getSamplesList(String sshOutput) {
+    return GSON.fromJson(sshOutput, new TypeToken<List<String>>() {}.getType());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/LifecycleListenersIT.java b/javatests/com/google/gerrit/acceptance/ssh/LifecycleListenersIT.java
new file mode 100644
index 0000000..0596cad
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/LifecycleListenersIT.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.acceptance.AbstractLifecycleListenersTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.inject.Inject;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class LifecycleListenersIT extends AbstractLifecycleListenersTest {
+  @Inject private InvocationCheck invocationCheck;
+
+  @Before
+  public void before() {
+    invocationCheck.setStartInvoked(false);
+    invocationCheck.setStopInvoked(false);
+  }
+
+  @Test
+  public void lifecycleListenerSuccessfulInvocation() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+      adminSshSession.exec("gerrit query --format json status:open --my-plugin--opt");
+      adminSshSession.assertSuccess();
+      assertTrue(invocationCheck.isStartInvoked());
+      assertTrue(invocationCheck.isStopInvoked());
+    }
+  }
+
+  @Test
+  public void lifecycleListenerUnsuccessfulInvocation() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+      adminSshSession.exec("gerrit ls-projects");
+      adminSshSession.assertSuccess();
+      assertFalse(invocationCheck.isStartInvoked());
+      assertFalse(invocationCheck.isStopInvoked());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
index 38293f9..009e05d 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.acceptance.ssh;
 
-import static com.google.common.truth.Truth.assertThat;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.collect.ImmutableListMultimap;
@@ -41,31 +40,12 @@
   private static final Gson GSON = OutputStreamQuery.GSON;
 
   @Test
-  public void queryChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))));
-  }
-
-  @Test
-  public void queryChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))));
-  }
-
-  @Test
   public void querySingleChangeWithBulkAttribute() throws Exception {
     getSingleChangeWithPluginDefinedBulkAttribute(
         id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))));
   }
 
   @Test
-  public void queryChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))),
-        (id, opts) -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id, opts))));
-  }
-
-  @Test
   public void queryPluginDefinedAttributeChangeWithOption() throws Exception {
     getChangeWithPluginDefinedBulkAttributeOption(
         id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))),
@@ -85,12 +65,6 @@
   }
 
   @Test
-  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
-    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
-        () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
-  }
-
-  @Test
   public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
     getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
         () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
@@ -116,15 +90,6 @@
   }
 
   @Nullable
-  private static List<PluginDefinedInfo> pluginInfoFromSingletonList(String sshOutput)
-      throws Exception {
-    List<Map<String, Object>> changeAttrs = getChangeAttrs(sshOutput);
-
-    assertThat(changeAttrs).hasSize(1);
-    return decodeRawPluginsList(GSON, changeAttrs.get(0).get("plugins"));
-  }
-
-  @Nullable
   private static Map<Change.Id, List<PluginDefinedInfo>> pluginInfosFromList(String sshOutput)
       throws Exception {
     List<Map<String, Object>> changeAttrs = getChangeAttrs(sshOutput);
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index bbe7b81..2b37cfd 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -45,6 +45,7 @@
       ImmutableList.of(
           "apropos",
           "close-connection",
+          "convert-ref-storage",
           "flush-caches",
           "gc",
           "logging",
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
index f6421a5..48fd38c 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
@@ -29,7 +28,6 @@
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.CurrentUser.PropertyKey;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -75,19 +73,6 @@
   }
 
   @Test
-  public void resetCurrentApiUserClearsCachedState() throws Exception {
-    requestScopeOperations.setApiUser(user.id());
-    PropertyKey<String> key = PropertyKey.create();
-    atrScope.get().getUser().put(key, "foo");
-    assertThat(atrScope.get().getUser().get(key)).hasValue("foo");
-
-    AcceptanceTestRequestScope.Context oldCtx = requestScopeOperations.resetCurrentApiUser();
-    checkCurrentUser(user.id());
-    assertThat(atrScope.get().getUser().get(key)).isEmpty();
-    assertThat(oldCtx.getUser().get(key)).hasValue("foo");
-  }
-
-  @Test
   public void setApiUserAnonymousSetsAnonymousUser() throws Exception {
     fastCheckCurrentUser(admin.id());
     requestScopeOperations.setApiUserAnonymous();
diff --git a/javatests/com/google/gerrit/auth/BUILD b/javatests/com/google/gerrit/auth/BUILD
new file mode 100644
index 0000000..6a41d01
--- /dev/null
+++ b/javatests/com/google/gerrit/auth/BUILD
@@ -0,0 +1,39 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "auth_tests",
+    size = "medium",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/server"],
+    tags = ["no_windows"],
+    visibility = ["//visibility:public"],
+    runtime_deps = [
+        "//java/com/google/gerrit/lucene",
+        "//lib/bouncycastle:bcprov",
+        "//prolog:gerrit-prolog-common",
+    ],
+    deps = [
+        "//java/com/google/gerrit/auth",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/proto/testing",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib:guava-retrying",
+        "//lib:jgit",
+        "//lib:jgit-junit",
+        "//lib:protobuf",
+        "//lib/flogger:api",
+        "//lib/guice",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
+        "//lib/truth:truth-proto-extension",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/auth/ldap/LdapRealmTest.java b/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
similarity index 91%
rename from javatests/com/google/gerrit/server/auth/ldap/LdapRealmTest.java
rename to javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
index 042222f..7a61626 100644
--- a/javatests/com/google/gerrit/server/auth/ldap/LdapRealmTest.java
+++ b/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
@@ -12,19 +12,19 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.auth.ldap.LdapModule.GROUP_CACHE;
+import static com.google.gerrit.auth.ldap.LdapModule.GROUP_EXIST_CACHE;
+import static com.google.gerrit.auth.ldap.LdapModule.PARENT_GROUPS_CACHE;
+import static com.google.gerrit.auth.ldap.LdapModule.USERNAME_CACHE;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_HTTP;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_HTTPS;
 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_XRI;
-import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_CACHE;
-import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_EXIST_CACHE;
-import static com.google.gerrit.server.auth.ldap.LdapModule.PARENT_GROUPS_CACHE;
-import static com.google.gerrit.server.auth.ldap.LdapModule.USERNAME_CACHE;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
diff --git a/javatests/com/google/gerrit/server/auth/oauth/OAuthRealmTest.java b/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
similarity index 98%
rename from javatests/com/google/gerrit/server/auth/oauth/OAuthRealmTest.java
rename to javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
index 8f7cc35..1af78e3 100644
--- a/javatests/com/google/gerrit/server/auth/oauth/OAuthRealmTest.java
+++ b/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.oauth;
+package com.google.gerrit.auth.oauth;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
diff --git a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java b/javatests/com/google/gerrit/auth/oauth/OAuthTokenCacheTest.java
similarity index 98%
rename from javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
rename to javatests/com/google/gerrit/auth/oauth/OAuthTokenCacheTest.java
index 64fa74f..e3357b8 100644
--- a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
+++ b/javatests/com/google/gerrit/auth/oauth/OAuthTokenCacheTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.oauth;
+package com.google.gerrit.auth.oauth;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
diff --git a/javatests/com/google/gerrit/server/auth/openid/OpenIdRealmTest.java b/javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java
similarity index 98%
rename from javatests/com/google/gerrit/server/auth/openid/OpenIdRealmTest.java
rename to javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java
index f5373ad..05b0ec0 100644
--- a/javatests/com/google/gerrit/server/auth/openid/OpenIdRealmTest.java
+++ b/javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.openid;
+package com.google.gerrit.auth.openid;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
index be35d5a..3036811 100644
--- a/javatests/com/google/gerrit/elasticsearch/BUILD
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -55,7 +55,6 @@
 ELASTICSEARCH_TAGS = [
     "docker",
     "elastic",
-    "exclusive",
 ]
 
 [junit_tests(
diff --git a/javatests/com/google/gerrit/entities/LabelFunctionTest.java b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
index 3941564..0132697 100644
--- a/javatests/com/google/gerrit/entities/LabelFunctionTest.java
+++ b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
@@ -24,7 +24,7 @@
 import org.junit.Test;
 
 public class LabelFunctionTest {
-  private static final String LABEL_NAME = "Verified";
+  private static final String LABEL_NAME = LabelId.VERIFIED;
   private static final LabelId LABEL_ID = LabelId.create(LABEL_NAME);
   private static final Change.Id CHANGE_ID = Change.id(100);
   private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
@@ -39,7 +39,7 @@
   public void checkLabelNameIsCorrect() {
     for (LabelFunction function : LabelFunction.values()) {
       SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
-      assertThat(myLabel.label).isEqualTo("Verified");
+      assertThat(myLabel.label).isEqualTo(LabelId.VERIFIED);
     }
   }
 
diff --git a/javatests/com/google/gerrit/entities/PermissionTest.java b/javatests/com/google/gerrit/entities/PermissionTest.java
index 2915f79..3175671 100644
--- a/javatests/com/google/gerrit/entities/PermissionTest.java
+++ b/javatests/com/google/gerrit/entities/PermissionTest.java
@@ -34,9 +34,9 @@
     assertThat(Permission.isPermission(Permission.ABANDON)).isTrue();
     assertThat(Permission.isPermission("no-permission")).isFalse();
 
-    assertThat(Permission.isPermission(Permission.LABEL + "Code-Review")).isTrue();
-    assertThat(Permission.isPermission(Permission.LABEL_AS + "Code-Review")).isTrue();
-    assertThat(Permission.isPermission("Code-Review")).isFalse();
+    assertThat(Permission.isPermission(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isPermission(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isPermission(LabelId.CODE_REVIEW)).isFalse();
   }
 
   @Test
@@ -44,9 +44,9 @@
     assertThat(Permission.hasRange(Permission.ABANDON)).isFalse();
     assertThat(Permission.hasRange("no-permission")).isFalse();
 
-    assertThat(Permission.hasRange(Permission.LABEL + "Code-Review")).isTrue();
-    assertThat(Permission.hasRange(Permission.LABEL_AS + "Code-Review")).isTrue();
-    assertThat(Permission.hasRange("Code-Review")).isFalse();
+    assertThat(Permission.hasRange(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.hasRange(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.hasRange(LabelId.CODE_REVIEW)).isFalse();
   }
 
   @Test
@@ -54,9 +54,9 @@
     assertThat(Permission.isLabel(Permission.ABANDON)).isFalse();
     assertThat(Permission.isLabel("no-permission")).isFalse();
 
-    assertThat(Permission.isLabel(Permission.LABEL + "Code-Review")).isTrue();
-    assertThat(Permission.isLabel(Permission.LABEL_AS + "Code-Review")).isFalse();
-    assertThat(Permission.isLabel("Code-Review")).isFalse();
+    assertThat(Permission.isLabel(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isLabel(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isFalse();
+    assertThat(Permission.isLabel(LabelId.CODE_REVIEW)).isFalse();
   }
 
   @Test
@@ -64,27 +64,30 @@
     assertThat(Permission.isLabelAs(Permission.ABANDON)).isFalse();
     assertThat(Permission.isLabelAs("no-permission")).isFalse();
 
-    assertThat(Permission.isLabelAs(Permission.LABEL + "Code-Review")).isFalse();
-    assertThat(Permission.isLabelAs(Permission.LABEL_AS + "Code-Review")).isTrue();
-    assertThat(Permission.isLabelAs("Code-Review")).isFalse();
+    assertThat(Permission.isLabelAs(Permission.LABEL + LabelId.CODE_REVIEW)).isFalse();
+    assertThat(Permission.isLabelAs(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isLabelAs(LabelId.CODE_REVIEW)).isFalse();
   }
 
   @Test
   public void forLabel() {
-    assertThat(Permission.forLabel("Code-Review")).isEqualTo(Permission.LABEL + "Code-Review");
+    assertThat(Permission.forLabel(LabelId.CODE_REVIEW))
+        .isEqualTo(Permission.LABEL + LabelId.CODE_REVIEW);
   }
 
   @Test
   public void forLabelAs() {
-    assertThat(Permission.forLabelAs("Code-Review")).isEqualTo(Permission.LABEL_AS + "Code-Review");
+    assertThat(Permission.forLabelAs(LabelId.CODE_REVIEW))
+        .isEqualTo(Permission.LABEL_AS + LabelId.CODE_REVIEW);
   }
 
   @Test
   public void extractLabel() {
-    assertThat(Permission.extractLabel(Permission.LABEL + "Code-Review")).isEqualTo("Code-Review");
-    assertThat(Permission.extractLabel(Permission.LABEL_AS + "Code-Review"))
-        .isEqualTo("Code-Review");
-    assertThat(Permission.extractLabel("Code-Review")).isNull();
+    assertThat(Permission.extractLabel(Permission.LABEL + LabelId.CODE_REVIEW))
+        .isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(Permission.extractLabel(Permission.LABEL_AS + LabelId.CODE_REVIEW))
+        .isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(Permission.extractLabel(LabelId.CODE_REVIEW)).isNull();
     assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
   }
 
@@ -92,17 +95,23 @@
   public void canBeOnAllProjects() {
     assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.ABANDON)).isTrue();
     assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)).isFalse();
-    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL + "Code-Review"))
+    assertThat(
+            Permission.canBeOnAllProjects(
+                AccessSection.ALL, Permission.LABEL + LabelId.CODE_REVIEW))
         .isTrue();
     assertThat(
-            Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL_AS + "Code-Review"))
+            Permission.canBeOnAllProjects(
+                AccessSection.ALL, Permission.LABEL_AS + LabelId.CODE_REVIEW))
         .isTrue();
 
     assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
     assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
-    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL + "Code-Review"))
+    assertThat(
+            Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL + LabelId.CODE_REVIEW))
         .isTrue();
-    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL_AS + "Code-Review"))
+    assertThat(
+            Permission.canBeOnAllProjects(
+                "refs/heads/*", Permission.LABEL_AS + LabelId.CODE_REVIEW))
         .isTrue();
   }
 
@@ -113,11 +122,11 @@
 
   @Test
   public void getLabel() {
-    assertThat(Permission.create(Permission.LABEL + "Code-Review").getLabel())
-        .isEqualTo("Code-Review");
-    assertThat(Permission.create(Permission.LABEL_AS + "Code-Review").getLabel())
-        .isEqualTo("Code-Review");
-    assertThat(Permission.create("Code-Review").getLabel()).isNull();
+    assertThat(Permission.create(Permission.LABEL + LabelId.CODE_REVIEW).getLabel())
+        .isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(Permission.create(Permission.LABEL_AS + LabelId.CODE_REVIEW).getLabel())
+        .isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(Permission.create(LabelId.CODE_REVIEW).getLabel()).isNull();
     assertThat(Permission.create(Permission.ABANDON).getLabel()).isNull();
   }
 
diff --git a/javatests/com/google/gerrit/entities/SubmitRecordTest.java b/javatests/com/google/gerrit/entities/SubmitRecordTest.java
index 0e832f4..578bc18 100644
--- a/javatests/com/google/gerrit/entities/SubmitRecordTest.java
+++ b/javatests/com/google/gerrit/entities/SubmitRecordTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.ImmutableList;
 import java.util.ArrayList;
 import java.util.Collection;
 import org.junit.Test;
@@ -67,4 +68,20 @@
 
     assertThat(SubmitRecord.allRecordsOK(submitRecords)).isFalse();
   }
+
+  @Test
+  public void deepCopy() {
+    SubmitRecord record = new SubmitRecord();
+    record.status = SubmitRecord.Status.CLOSED;
+    record.errorMessage = "ouch";
+    record.requirements =
+        ImmutableList.of(
+            LegacySubmitRequirement.builder().setFallbackText("foo").setType("baz").build());
+    SubmitRecord.Label label = new SubmitRecord.Label();
+    label.label = "Code-Review";
+    record.labels = ImmutableList.of(label);
+
+    assertThat(record).isNotSameInstanceAs(record.deepCopy());
+    assertThat(record).isEqualTo(record.deepCopy());
+  }
 }
diff --git a/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java
index 0e4fbc8..12045b1 100644
--- a/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class AccountIdProtoConverterTest {
@@ -48,17 +47,6 @@
     assertThat(convertedAccountId).isEqualTo(accountId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Account_Id proto = Entities.Account_Id.newBuilder().setId(24).build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Account_Id> parser = accountIdProtoConverter.getParser();
-    Entities.Account_Id parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java
index 0a73db8..7073fa7 100644
--- a/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
 
@@ -55,21 +54,6 @@
     assertThat(convertedNameKey).isEqualTo(nameKey);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Branch_NameKey proto =
-        Entities.Branch_NameKey.newBuilder()
-            .setProject(Entities.Project_NameKey.newBuilder().setName("project 1"))
-            .setBranch("branch 36")
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Branch_NameKey> parser = branchNameKeyProtoConverter.getParser();
-    Entities.Branch_NameKey parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java
index 12f3f33..fe71c42 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class ChangeIdProtoConverterTest {
@@ -48,17 +47,6 @@
     assertThat(convertedChangeId).isEqualTo(changeId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Change_Id proto = Entities.Change_Id.newBuilder().setId(94).build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Change_Id> parser = changeIdProtoConverter.getParser();
-    Entities.Change_Id parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java
index e9080b3..745c90c 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class ChangeKeyProtoConverterTest {
@@ -48,17 +47,6 @@
     assertThat(convertedChangeKey).isEqualTo(changeKey);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Change_Key proto = Entities.Change_Key.newBuilder().setId("change 36").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Change_Key> parser = changeKeyProtoConverter.getParser();
-    Entities.Change_Key parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java
index 72ce896..98329d2 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
 
@@ -55,21 +54,6 @@
     assertThat(convertedMessageKey).isEqualTo(messageKey);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.ChangeMessage_Key proto =
-        Entities.ChangeMessage_Key.newBuilder()
-            .setChangeId(Entities.Change_Id.newBuilder().setId(704))
-            .setUuid("aabbcc")
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.ChangeMessage_Key> parser = messageKeyProtoConverter.getParser();
-    Entities.ChangeMessage_Key parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
index 933ffb4..b185558 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import org.junit.Test;
@@ -174,23 +173,6 @@
     assertThat(convertedChangeMessage).isEqualTo(changeMessage);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.ChangeMessage proto =
-        Entities.ChangeMessage.newBuilder()
-            .setKey(
-                Entities.ChangeMessage_Key.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(543))
-                    .setUuid("change-message-21"))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.ChangeMessage> parser = changeMessageProtoConverter.getParser();
-    Entities.ChangeMessage parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index bc669cc..ae8e06d 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import org.junit.Test;
@@ -277,40 +276,6 @@
     assertThat(change.getLastUpdatedOn()).isEqualTo(new Timestamp(987654L));
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Change proto =
-        Entities.Change.newBuilder()
-            .setChangeId(Entities.Change_Id.newBuilder().setId(14))
-            .setChangeKey(Entities.Change_Key.newBuilder().setId("change 1"))
-            .setRowVersion(0)
-            .setCreatedOn(987654L)
-            .setLastUpdatedOn(1234567L)
-            .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
-            .setDest(
-                Entities.Branch_NameKey.newBuilder()
-                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
-                    .setBranch("branch 74"))
-            .setStatus(Change.STATUS_MERGED)
-            .setCurrentPatchSetId(23)
-            .setSubject("subject XYZ")
-            .setTopic("my topic")
-            .setOriginalSubject("original subject ABC")
-            .setSubmissionId("submission ID 234")
-            .setAssignee(Entities.Account_Id.newBuilder().setId(100001))
-            .setIsPrivate(true)
-            .setWorkInProgress(true)
-            .setReviewStarted(true)
-            .setRevertOf(Entities.Change_Id.newBuilder().setId(180))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Change> parser = changeProtoConverter.getParser();
-    Entities.Change parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java
index 88b9fb6..6237ac0 100644
--- a/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class LabelIdProtoConverterTest {
@@ -48,17 +47,6 @@
     assertThat(convertedLabelId).isEqualTo(labelId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.LabelId proto = Entities.LabelId.newBuilder().setId("label-23").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.LabelId> parser = labelIdProtoConverter.getParser();
-    Entities.LabelId parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java
index 8408b69..447c47f 100644
--- a/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java
@@ -20,7 +20,6 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
@@ -48,18 +47,6 @@
     assertThat(convertedObjectId).isEqualTo(objectId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.ObjectId proto =
-        Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.ObjectId> parser = objectIdProtoConverter.getParser();
-    Entities.ObjectId parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java
index 11aac0d..be55561 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
 
@@ -65,25 +64,6 @@
     assertThat(convertedKey).isEqualTo(key);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSetApproval_Key proto =
-        Entities.PatchSetApproval_Key.newBuilder()
-            .setPatchSetId(
-                Entities.PatchSet_Id.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                    .setId(14))
-            .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-            .setLabelId(Entities.LabelId.newBuilder().setId("label-8"))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSetApproval_Key> parser = protoConverter.getParser();
-    Entities.PatchSetApproval_Key parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
index bca5eea..bf39ff8 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.inject.TypeLiteral;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.util.Date;
@@ -165,29 +164,6 @@
     assertThat(patchSetApproval.postSubmit()).isEqualTo(false);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSetApproval proto =
-        Entities.PatchSetApproval.newBuilder()
-            .setKey(
-                Entities.PatchSetApproval_Key.newBuilder()
-                    .setPatchSetId(
-                        Entities.PatchSet_Id.newBuilder()
-                            .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                            .setId(14))
-                    .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
-            .setValue(456)
-            .setGranted(987654L)
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSetApproval> parser = protoConverter.getParser();
-    Entities.PatchSetApproval parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java
index 530b431..c858582 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
 
@@ -55,21 +54,6 @@
     assertThat(convertedPatchSetId).isEqualTo(patchSetId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSet_Id proto =
-        Entities.PatchSet_Id.newBuilder()
-            .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-            .setId(73)
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSet_Id> parser = patchSetIdProtoConverter.getParser();
-    Entities.PatchSet_Id parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
index 2519e75..efeb24f 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.inject.TypeLiteral;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.util.Optional;
@@ -148,23 +147,6 @@
                 .build());
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSet proto =
-        Entities.PatchSet.newBuilder()
-            .setId(
-                Entities.PatchSet_Id.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-                    .setId(73))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSet> parser = patchSetProtoConverter.getParser();
-    Entities.PatchSet parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java
index 2f693e6..2fa89a5 100644
--- a/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class ProjectNameKeyProtoConverterTest {
@@ -50,18 +49,6 @@
     assertThat(convertedNameKey).isEqualTo(nameKey);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Project_NameKey proto =
-        Entities.Project_NameKey.newBuilder().setName("project 36").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Project_NameKey> parser = projectNameKeyProtoConverter.getParser();
-    Entities.Project_NameKey parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
new file mode 100644
index 0000000..4352fe8
--- /dev/null
+++ b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
@@ -0,0 +1,385 @@
+// 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 static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.client.ReviewerState;
+import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Map;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class ChangeInfoDifferTest {
+
+  private static final String REVISION = "abc123";
+
+  @Test
+  public void getDiff_givenEmptyChangeInfos_returnsEmptyDifference() {
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(new ChangeInfo(), new ChangeInfo());
+
+    // Spot check a few fields, including collections and maps.
+    assertThat(diff.added()._number).isNull();
+    assertThat(diff.added().branch).isNull();
+    assertThat(diff.added().project).isNull();
+    assertThat(diff.added().currentRevision).isNull();
+    assertThat(diff.added().actions).isNull();
+    assertThat(diff.added().messages).isNull();
+    assertThat(diff.added().reviewers).isNull();
+    assertThat(diff.added().hashtags).isNull();
+    assertThat(diff.removed()._number).isNull();
+    assertThat(diff.removed().branch).isNull();
+    assertThat(diff.removed().project).isNull();
+    assertThat(diff.removed().currentRevision).isNull();
+    assertThat(diff.removed().actions).isNull();
+    assertThat(diff.removed().messages).isNull();
+    assertThat(diff.removed().reviewers).isNull();
+    assertThat(diff.removed().hashtags).isNull();
+  }
+
+  @Test
+  public void getDiff_givenUnchangedTopic_returnsNullTopics() {
+    ChangeInfo oldChangeInfo = createChangeInfoWithTopic("topic");
+    ChangeInfo newChangeInfo = createChangeInfoWithTopic(oldChangeInfo.topic);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().topic).isNull();
+    assertThat(diff.removed().topic).isNull();
+  }
+
+  @Test
+  public void getDiff_givenChangedTopic_returnsTopics() {
+    ChangeInfo oldChangeInfo = createChangeInfoWithTopic("old-topic");
+    ChangeInfo newChangeInfo = createChangeInfoWithTopic("new-topic");
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().topic).isEqualTo(newChangeInfo.topic);
+    assertThat(diff.removed().topic).isEqualTo(oldChangeInfo.topic);
+  }
+
+  @Test
+  public void getDiff_givenEqualAssignees_returnsNullAssignee() {
+    ChangeInfo oldChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
+    ChangeInfo newChangeInfo =
+        createChangeInfoWithAccount(
+            new AccountInfo(oldChangeInfo.assignee.name, oldChangeInfo.assignee.email));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().assignee).isNull();
+    assertThat(diff.removed().assignee).isNull();
+  }
+
+  @Test
+  public void getDiff_givenNewAssignee_returnsAssignee() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().assignee).isEqualTo(newChangeInfo.assignee);
+    assertThat(diff.removed().assignee).isNull();
+  }
+
+  @Test
+  public void getDiff_withRemovedAssignee_returnsAssignee() {
+    ChangeInfo oldChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
+    ChangeInfo newChangeInfo = new ChangeInfo();
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().assignee).isNull();
+    assertThat(diff.removed().assignee).isEqualTo(oldChangeInfo.assignee);
+  }
+
+  @Test
+  public void getDiff_givenAssigneeWithNewName_returnsNameButNotEmail() {
+    ChangeInfo oldChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("old name", "mail@mail.com"));
+    ChangeInfo newChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("new name", oldChangeInfo.assignee.email));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().assignee).isNotNull();
+    assertThat(diff.added().assignee.name).isEqualTo(newChangeInfo.assignee.name);
+    assertThat(diff.added().assignee.email).isNull();
+    assertThat(diff.removed().assignee).isNotNull();
+    assertThat(diff.removed().assignee.name).isEqualTo(oldChangeInfo.assignee.name);
+    assertThat(diff.removed().assignee.email).isNull();
+  }
+
+  @Test
+  public void getDiff_whenHashtagsChanged_returnsHashtags() {
+    String removedHashtag = "removed";
+    String addedHashtag = "added";
+    ChangeInfo oldChangeInfo = createChangeInfoWithHashtags(removedHashtag, "existing");
+    ChangeInfo newChangeInfo = createChangeInfoWithHashtags("existing", addedHashtag);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().hashtags).isNotNull();
+    assertThat(diff.added().hashtags).containsExactly(addedHashtag);
+    assertThat(diff.removed().hashtags).isNotNull();
+    assertThat(diff.removed().hashtags).containsExactly(removedHashtag);
+  }
+
+  @Test
+  public void getDiff_whenDuplicateHashtagAdded_returnsHashtag() {
+    String hashtag = "hashtag";
+    ChangeInfo oldChangeInfo = createChangeInfoWithHashtags(hashtag, hashtag);
+    ChangeInfo newChangeInfo = createChangeInfoWithHashtags(hashtag, hashtag, hashtag);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().hashtags).isNotNull();
+    assertThat(diff.added().hashtags).containsExactly(hashtag);
+    assertThat(diff.removed().hashtags).isNull();
+  }
+
+  @Test
+  public void getDiff_whenChangeMessageUnchanged_returnsNullMessage() {
+    String message = "message";
+    ChangeInfo oldChangeInfo = new ChangeInfo(new ChangeMessageInfo(message));
+    ChangeInfo newChangeInfo = new ChangeInfo(new ChangeMessageInfo(message));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().messages).isNull();
+    assertThat(diff.removed().messages).isNull();
+  }
+
+  @Test
+  public void getDiff_whenChangeMessageAdded_returnsAdded() {
+    ChangeMessageInfo addedMessage = new ChangeMessageInfo("added");
+    ChangeMessageInfo existingMessage = new ChangeMessageInfo("existing");
+    ChangeInfo oldChangeInfo = new ChangeInfo(existingMessage);
+    ChangeInfo newChangeInfo = new ChangeInfo(existingMessage, addedMessage);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().messages).isNotNull();
+    assertThat(diff.added().messages).containsExactly(addedMessage);
+    assertThat(diff.removed().messages).isNull();
+  }
+
+  @Test
+  public void getDiff_whenChangeMessageRemoved_returnsRemoved() {
+    ChangeMessageInfo removedMessage = new ChangeMessageInfo("removed");
+    ChangeMessageInfo existingMessage = new ChangeMessageInfo("existing");
+    ChangeInfo oldChangeInfo = new ChangeInfo(existingMessage, removedMessage);
+    ChangeInfo newChangeInfo = new ChangeInfo(existingMessage);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().messages).isNull();
+    assertThat(diff.removed().messages).isNotNull();
+    assertThat(diff.removed().messages).containsExactly(removedMessage);
+  }
+
+  @Test
+  public void getDiff_whenDuplicateMessagesAdded_returnsDuplicates() {
+    ChangeMessageInfo message = new ChangeMessageInfo("message");
+    ChangeInfo oldChangeInfo = new ChangeInfo(message, message);
+    ChangeInfo newChangeInfo = new ChangeInfo(message, message, message, message);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().messages).isNotNull();
+    assertThat(diff.added().messages).containsExactly(message, message);
+    assertThat(diff.removed().messages).isNull();
+  }
+
+  @Test
+  public void getDiff_whenNoNewRevisions_returnsNullRevisions() {
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, new RevisionInfo("ref")));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, new RevisionInfo("ref")));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNull();
+    assertThat(diff.removed().revisions).isNull();
+  }
+
+  @Test
+  public void getDiff_whenOneAddedRevision_returnsRevision() {
+    RevisionInfo addedRevision = new RevisionInfo("ref");
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of());
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, addedRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNotNull();
+    assertThat(diff.added().revisions).hasSize(1);
+    assertThat(diff.added().revisions).containsKey(REVISION);
+    assertThat(diff.added().revisions.get(REVISION).ref).isEqualTo(addedRevision.ref);
+    assertThat(diff.removed().revisions).isNull();
+  }
+
+  @Test
+  public void getDiff_whenOneModifiedRevision_returnsModificationsToRevision() {
+    RevisionInfo oldRevision = new RevisionInfo("ref", 1);
+    RevisionInfo newRevision = new RevisionInfo(oldRevision.ref, 2);
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, oldRevision));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, newRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNotNull();
+    assertThat(diff.added().revisions).hasSize(1);
+    assertThat(diff.added().revisions).containsKey(REVISION);
+    assertThat(diff.added().revisions.get(REVISION).ref).isNull();
+    assertThat(diff.added().revisions.get(REVISION)._number).isEqualTo(newRevision._number);
+    assertThat(diff.removed().revisions).isNotNull();
+    assertThat(diff.removed().revisions).hasSize(1);
+    assertThat(diff.removed().revisions).containsKey(REVISION);
+    assertThat(diff.removed().revisions.get(REVISION).ref).isNull();
+    assertThat(diff.removed().revisions.get(REVISION)._number).isEqualTo(oldRevision._number);
+  }
+
+  @Test
+  public void getDiff_whenOneModifiedRevisionUploader_returnsModificationsToRevisionUploader() {
+    RevisionInfo oldRevision = new RevisionInfo(new AccountInfo("name", "email@mail.com"));
+    RevisionInfo newRevision =
+        new RevisionInfo(
+            new AccountInfo(oldRevision.uploader.name, oldRevision.uploader.email + "2"));
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, oldRevision));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, newRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNotNull();
+    assertThat(diff.added().revisions).hasSize(1);
+    assertThat(diff.added().revisions).containsKey(REVISION);
+    assertThat(diff.added().revisions.get(REVISION).uploader).isNotNull();
+    assertThat(diff.added().revisions.get(REVISION).uploader.name).isNull();
+    assertThat(diff.added().revisions.get(REVISION).uploader.email)
+        .isEqualTo(newRevision.uploader.email);
+    assertThat(diff.removed().revisions).isNotNull();
+    assertThat(diff.removed().revisions).hasSize(1);
+    assertThat(diff.removed().revisions).containsKey(REVISION);
+    assertThat(diff.removed().revisions.get(REVISION).uploader).isNotNull();
+    assertThat(diff.removed().revisions.get(REVISION).uploader.name).isNull();
+    assertThat(diff.removed().revisions.get(REVISION).uploader.email)
+        .isEqualTo(oldRevision.uploader.email);
+  }
+
+  @Test
+  public void getDiff_whenOneUnchangedRevisionUploader_returnsNullRevision() {
+    RevisionInfo oldRevision = new RevisionInfo(new AccountInfo("name", "email@mail.com"));
+    RevisionInfo newRevision = new RevisionInfo(oldRevision.uploader);
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, oldRevision));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, newRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNull();
+    assertThat(diff.removed().revisions).isNull();
+  }
+
+  @Test
+  public void getDiff_assertCanConstructAllChangeInfoReferences() throws Exception {
+    buildObjectWithFullFields(ChangeInfo.class);
+  }
+
+  @Test
+  public void getDiff_arrayListInMap() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+
+    AccountInfo i1 = new AccountInfo();
+    i1._accountId = 1;
+    AccountInfo i2 = new AccountInfo();
+    i2._accountId = 2;
+
+    ArrayList<AccountInfo> a1 = new ArrayList<>();
+    ArrayList<AccountInfo> a2 = new ArrayList<>();
+
+    a1.add(i1);
+    a2.add(i1);
+    a2.add(i2);
+    oldChangeInfo.reviewers = ImmutableMap.of(ReviewerState.REVIEWER, a1);
+    newChangeInfo.reviewers = ImmutableMap.of(ReviewerState.REVIEWER, a2);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+    assertThat(diff.added().reviewers).hasSize(1);
+    assertThat(diff.added().reviewers).containsKey(ReviewerState.REVIEWER);
+    assertThat(diff.added().reviewers.get(ReviewerState.REVIEWER)).containsExactly(i2);
+    assertThat(diff.removed().reviewers).isNull();
+  }
+
+  private static Object buildObjectWithFullFields(Class<?> c) throws Exception {
+    if (c == null) {
+      return null;
+    }
+    Object toPopulate = ChangeInfoDiffer.construct(c);
+    for (Field field : toPopulate.getClass().getDeclaredFields()) {
+      Class<?> parameterizedType = getParameterizedType(field);
+      if (!ChangeInfoDiffer.isSimple(field.getType())
+          && !field.getType().isArray()
+          && !Map.class.isAssignableFrom(field.getType())
+          && !Collection.class.isAssignableFrom(field.getType())) {
+        field.set(toPopulate, buildObjectWithFullFields(field.getType()));
+      } else if (Collection.class.isAssignableFrom(field.getType())
+          && parameterizedType != null
+          && !ChangeInfoDiffer.isSimple(parameterizedType)) {
+        field.set(toPopulate, ImmutableList.of(buildObjectWithFullFields(parameterizedType)));
+      }
+    }
+    return toPopulate;
+  }
+
+  private static Class<?> getParameterizedType(Field field) {
+    if (!Collection.class.isAssignableFrom(field.getType())) {
+      return null;
+    }
+    Type genericType = field.getGenericType();
+    if (genericType instanceof ParameterizedType) {
+      return (Class<?>) ((ParameterizedType) genericType).getActualTypeArguments()[0];
+    }
+    return null;
+  }
+
+  private static ChangeInfo createChangeInfoWithTopic(String topic) {
+    ChangeInfo changeInfo = new ChangeInfo();
+    changeInfo.topic = topic;
+    return changeInfo;
+  }
+
+  private static ChangeInfo createChangeInfoWithAccount(AccountInfo accountInfo) {
+    ChangeInfo changeInfo = new ChangeInfo();
+    changeInfo.assignee = accountInfo;
+    return changeInfo;
+  }
+
+  private static ChangeInfo createChangeInfoWithHashtags(String... hashtags) {
+    ChangeInfo changeInfo = new ChangeInfo();
+    changeInfo.hashtags = ImmutableList.copyOf(hashtags);
+    return changeInfo;
+  }
+}
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index ba9475f..634231f 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -15,19 +15,24 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
-import static java.util.stream.Collectors.joining;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.Accounts;
 import com.google.gerrit.extensions.api.config.Config;
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.experiments.ConfigExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
+import java.util.ArrayList;
+import java.util.List;
 import org.junit.Test;
 
 public class IndexServletTest {
@@ -55,14 +60,19 @@
     String testCdnPath = "bar-cdn";
     String testFaviconURL = "zaz-url";
 
-    String disabledDefault = IndexHtmlUtil.DEFAULT_EXPERIMENTS.asList().get(0);
+    // Pick any known experiment enabled by default;
+    String disabledDefault = ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS;
+    assertThat(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES).contains(disabledDefault);
+
     org.eclipse.jgit.lib.Config serverConfig = new org.eclipse.jgit.lib.Config();
     serverConfig.setStringList(
         "experiments", null, "enabled", ImmutableList.of("NewFeature", "DisabledFeature"));
     serverConfig.setStringList(
         "experiments", null, "disabled", ImmutableList.of("DisabledFeature", disabledDefault));
+    ExperimentFeatures experimentFeatures = new ConfigExperimentFeatures(serverConfig);
     IndexServlet servlet =
-        new IndexServlet(testCanonicalUrl, testCdnPath, testFaviconURL, gerritApi, serverConfig);
+        new IndexServlet(
+            testCanonicalUrl, testCdnPath, testFaviconURL, gerritApi, experimentFeatures);
 
     FakeHttpServletResponse response = new FakeHttpServletResponse();
 
@@ -85,14 +95,17 @@
                 + "\\x22\\/config\\/server\\/info\\x22: \\x7b\\x22default_theme\\x22:"
                 + "\\x22my-default-theme\\x22\\x7d, \\x22\\/config\\/server\\/top-menus\\x22: "
                 + "\\x5b\\x5d\\x7d');");
-    String enabledDefaults =
-        IndexHtmlUtil.DEFAULT_EXPERIMENTS.stream()
+    ImmutableSet<String> enabledDefaults =
+        ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES.stream()
             .filter(e -> !e.equals(disabledDefault))
-            .collect(joining("\\x22,"));
+            .collect(ImmutableSet.toImmutableSet());
+    List<String> expectedEnabled = new ArrayList<>();
+    expectedEnabled.add("NewFeature");
+    expectedEnabled.addAll(enabledDefaults);
     assertThat(output)
         .contains(
-            "window.ENABLED_EXPERIMENTS = JSON.parse('\\x5b\\x22NewFeature\\x22,\\x22"
-                + enabledDefaults
+            "window.ENABLED_EXPERIMENTS = JSON.parse('\\x5b\\x22"
+                + String.join("\\x22,", expectedEnabled)
                 + "\\x22\\x5d');</script>");
   }
 }
diff --git a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
index 3ffd61e..892cabe 100644
--- a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
+++ b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
@@ -42,7 +42,6 @@
 import java.io.File;
 import java.net.InetSocketAddress;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.junit.BeforeClass;
 import org.junit.Test;
 
@@ -91,15 +90,13 @@
       projectOperations
           .project(project)
           .forUpdate()
-          .add(deny(Permission.READ).ref("refs/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+          .add(deny(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.ANONYMOUS_USERS))
           .add(
               allow(Permission.READ)
                   .ref("refs/heads/master")
                   .group(SystemGroupBackend.REGISTERED_USERS))
           .update();
 
-      setProtocolV2(project);
-
       // Retrieve HTTP url
       String url = config.getString("gerrit", null, "canonicalweburl");
       String urlDestinationTemplate =
@@ -214,8 +211,6 @@
       Project.NameKey allRefsVisibleProject = Project.nameKey("all-refs-visible");
       gApi.projects().create(allRefsVisibleProject.get());
 
-      setProtocolV2(allRefsVisibleProject);
-
       // Set up project permission to allow reading all refs
       projectOperations
           .project(allRefsVisibleProject)
@@ -270,8 +265,6 @@
       Project.NameKey privateProject = Project.nameKey("private-project");
       gApi.projects().create(privateProject.get());
 
-      setProtocolV2(privateProject);
-
       // Disallow general read permissions for anonymous users
       projectOperations
           .project(allProjectsName)
@@ -339,12 +332,6 @@
                 UTF_8));
   }
 
-  private void setProtocolV2(Project.NameKey projectName) throws Exception {
-    execute(
-        ImmutableList.of("git", "config", "protocol.version", "2"),
-        sitePaths.site_path.resolve("git").resolve(projectName.get() + Constants.DOT_GIT).toFile());
-  }
-
   private static void assertGitProtocolV2Refs(String commit, String out) {
     assertThat(out).containsMatch("(git|ls-remote)< version 2");
     assertThat(out).contains("refs/changes/01/1/1");
diff --git a/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java b/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
new file mode 100644
index 0000000..c33ef8e
--- /dev/null
+++ b/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
@@ -0,0 +1,100 @@
+// 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.integration.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AccountCreator;
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class NoAccessSameAsNotFoundIT extends StandaloneSiteTest {
+  private static final String PASSWORD = "secret";
+  private static final String REPO = "foo";
+
+  @Inject private @GerritServerConfig Config config;
+  @Inject private AccountCreator accountCreator;
+  @Inject private GerritApi gApi;
+  @Inject private ProjectOperations projectOperations;
+
+  private String repoUrl;
+
+  @Test
+  public void sameResponseForNonExistingAndNonAccessibleRepo() throws Exception {
+    try (ServerContext ctx = startServer()) {
+      setup(ctx);
+
+      // clone non-existing REPO
+      String nonExistingResponse = cloneRepo();
+
+      // create REPO and make it non-accessible then clone it again
+      createNonAccessibleRepo();
+      String noAccessResponse = cloneRepo();
+
+      // make sure the response is identical in both cases
+      assertThat(noAccessResponse).isEqualTo(nonExistingResponse);
+    }
+  }
+
+  private void setup(ServerContext ctx) throws Exception {
+    ctx.getInjector().injectMembers(this);
+
+    TestAccount user = accountCreator.user();
+    gApi.accounts().id(user.username()).setHttpPassword(PASSWORD);
+
+    String canonical = config.getString("gerrit", null, "canonicalweburl");
+    repoUrl =
+        String.format(
+            "http://%s:%s@%s/a/%s", user.username(), PASSWORD, canonical.substring(7), REPO);
+  }
+
+  private String cloneRepo() {
+    try {
+      return execute(ImmutableList.of("git", "clone", repoUrl));
+    } catch (Exception e) {
+      return e.getMessage();
+    }
+  }
+
+  private void createNonAccessibleRepo() throws RestApiException {
+    // Create project
+    Project.NameKey project = Project.nameKey(REPO);
+    gApi.projects().create(project.get());
+
+    // Block access for everyone
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+        .update();
+  }
+
+  private String execute(ImmutableList<String> cmd) throws Exception {
+    return execute(cmd, sitePaths.data_dir.toFile(), ImmutableMap.of());
+  }
+}
diff --git a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
index 66b02ff..c720905 100644
--- a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
+++ b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
@@ -168,7 +168,11 @@
         .add("archive")
         .add("-f=" + format)
         .add("--prefix=" + commit + "/")
+        // --remote makes git execute "git archive" on the server through SSH.
+        // The Gerrit/JGit version of the command understands the --compression-level
+        // argument below.
         .add("--remote=" + sshDestination)
+        .add("--compression-level=1") // set to 1 to reduce the memory footprint
         .add(commit)
         .add(FILE_NAME)
         .build();
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 248c7d1..7ab7ae9 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -49,6 +49,7 @@
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/proto/testing",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/account/externalids/testing",
@@ -73,6 +74,7 @@
         "//lib:jgit",
         "//lib:jgit-junit",
         "//lib:protobuf",
+        "//lib:soy",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
@@ -82,5 +84,6 @@
         "//lib/truth:truth-java8-extension",
         "//lib/truth:truth-proto-extension",
         "//proto:cache_java_proto",
+        "//proto:entities_java_proto",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
index 1672ce1..463af35 100644
--- a/javatests/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -25,7 +25,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.EnableReverseDnsLookup;
+import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -80,8 +80,8 @@
           @Override
           protected void configure() {
             bind(Boolean.class)
-                .annotatedWith(EnableReverseDnsLookup.class)
-                .toInstance(Boolean.TRUE);
+                .annotatedWith(EnablePeerIPInReflogRecord.class)
+                .toInstance(Boolean.FALSE);
             bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(config);
             bind(String.class)
                 .annotatedWith(AnonymousCowardName.class)
diff --git a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
new file mode 100644
index 0000000..8fe7662
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
@@ -0,0 +1,42 @@
+package com.google.gerrit.server.cache.serialize;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.comment.CommentContextCacheImpl.CommentContextSerializer.INSTANCE;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.comment.CommentContextKey;
+import org.junit.Test;
+
+public class CommentContextSerializerTest {
+  @Test
+  public void roundTripValue() {
+    CommentContext commentContext =
+        CommentContext.create(ImmutableMap.of(1, "line_1", 2, "line_2"), "text/x-java");
+
+    byte[] serialized = INSTANCE.serialize(commentContext);
+    CommentContext deserialized = INSTANCE.deserialize(serialized);
+
+    assertThat(commentContext).isEqualTo(deserialized);
+  }
+
+  @Test
+  public void roundTripKey() {
+    Project.NameKey proj = Project.NameKey.parse("project");
+    Change.Id changeId = Change.Id.tryParse("1234").get();
+
+    CommentContextKey k =
+        CommentContextKey.builder()
+            .project(proj)
+            .changeId(changeId)
+            .id("commentId")
+            .path("pathHash")
+            .patchset(1)
+            .contextPadding(3)
+            .build();
+    byte[] serialized = CommentContextKey.Serializer.INSTANCE.serialize(k);
+    assertThat(k).isEqualTo(CommentContextKey.Serializer.INSTANCE.deserialize(serialized));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
index 7fe73d5..8df9292 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -4,13 +4,11 @@
     name = "tests",
     srcs = glob(["*.java"]),
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/serialize/entities",
-        "//java/com/google/gerrit/server/cache/testing",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/server/util/time",
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffCacheKeySerializerTest.java
new file mode 100644
index 0000000..ecab07d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffCacheKeySerializerTest.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.server.patch.filediff.FileDiffCacheKey;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+/** Tests the protobuf serializer for the {@link FileDiffCacheKey}. */
+public class FileDiffCacheKeySerializerTest {
+  private static final ObjectId COMMIT_ID_1 =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId COMMIT_ID_2 =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    FileDiffCacheKey key =
+        FileDiffCacheKey.builder()
+            .project(Project.nameKey("project/x"))
+            .oldCommit(COMMIT_ID_1)
+            .newCommit(COMMIT_ID_2)
+            .newFilePath("some_file.txt")
+            .renameScore(65)
+            .diffAlgorithm(DiffAlgorithm.HISTOGRAM)
+            .whitespace(Whitespace.IGNORE_ALL)
+            .build();
+
+    byte[] serialized = FileDiffCacheKey.Serializer.INSTANCE.serialize(key);
+
+    assertThat(FileDiffCacheKey.Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
new file mode 100644
index 0000000..17fd959
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.PatchType;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.filediff.Edit;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.filediff.TaggedEdit;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class FileDiffOutputSerializerTest {
+  @Test
+  public void roundTrip() {
+    ImmutableList<TaggedEdit> edits =
+        ImmutableList.of(
+            TaggedEdit.create(Edit.create(1, 5, 3, 4), true),
+            TaggedEdit.create(Edit.create(21, 30, 150, 158), false));
+
+    FileDiffOutput fileDiff =
+        FileDiffOutput.builder()
+            .oldCommitId(ObjectId.fromString("dd4d2a1498870ca5fe415b33f65d052d69d9eaf5"))
+            .newCommitId(ObjectId.fromString("0cfaab3f2ba76f71798da0a2651f41be8d45f842"))
+            .comparisonType(ComparisonType.againstOtherPatchSet())
+            .oldPath(Optional.of("old_file_path.txt"))
+            .newPath(Optional.empty())
+            .changeType(ChangeType.DELETED)
+            .patchType(Optional.of(PatchType.UNIFIED))
+            .size(23)
+            .sizeDelta(10)
+            .headerLines(ImmutableList.of("header line 1", "header line 2"))
+            .edits(edits)
+            .build();
+
+    byte[] serialized = FileDiffOutput.Serializer.INSTANCE.serialize(fileDiff);
+    assertThat(FileDiffOutput.Serializer.INSTANCE.deserialize(serialized)).isEqualTo(fileDiff);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
new file mode 100644
index 0000000..12d8d00
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
@@ -0,0 +1,49 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheKey;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class GitFileDiffKeySerializerTest {
+  private static final ObjectId TREE_ID_1 =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId TREE_ID_2 =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    GitFileDiffCacheKey key =
+        GitFileDiffCacheKey.builder()
+            .project(Project.nameKey("project/x"))
+            .oldTree(TREE_ID_1)
+            .newTree(TREE_ID_2)
+            .newFilePath("some_file.txt")
+            .renameScore(65)
+            .diffAlgorithm(DiffAlgorithm.HISTOGRAM)
+            .whitespace(Whitespace.IGNORE_ALL)
+            .build();
+
+    byte[] serialized = GitFileDiffCacheKey.Serializer.INSTANCE.serialize(key);
+
+    assertThat(GitFileDiffCacheKey.Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java
new file mode 100644
index 0000000..93441a4
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.FileMode;
+import com.google.gerrit.entities.Patch.PatchType;
+import com.google.gerrit.server.patch.filediff.Edit;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiff;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiff.Serializer;
+import java.util.Optional;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class GitFileDiffSerializerTest {
+  private static final ObjectId OLD_ID =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId NEW_ID =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    ImmutableList<Edit> edits =
+        ImmutableList.of(Edit.create(1, 5, 3, 4), Edit.create(21, 30, 150, 158));
+
+    GitFileDiff gitFileDiff =
+        GitFileDiff.builder()
+            .edits(edits)
+            .fileHeader("file_header")
+            .oldPath(Optional.of("old_file_path.txt"))
+            .newPath(Optional.empty())
+            .oldId(AbbreviatedObjectId.fromObjectId(OLD_ID))
+            .newId(AbbreviatedObjectId.fromObjectId(NEW_ID))
+            .changeType(ChangeType.DELETED)
+            .patchType(Optional.of(PatchType.UNIFIED))
+            .oldMode(Optional.of(FileMode.REGULAR_FILE))
+            .newMode(Optional.of(FileMode.REGULAR_FILE))
+            .build();
+
+    byte[] serialized = Serializer.INSTANCE.serialize(gitFileDiff);
+    assertThat(Serializer.INSTANCE.deserialize(serialized)).isEqualTo(gitFileDiff);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
new file mode 100644
index 0000000..caf1fbb
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
@@ -0,0 +1,43 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey.Serializer;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class GitModifiedFilesCacheKeySerializerTest {
+  private static final ObjectId TREE_ID_1 =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId TREE_ID_2 =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    GitModifiedFilesCacheKey key =
+        GitModifiedFilesCacheKey.builder()
+            .project(Project.NameKey.parse("Project/X"))
+            .aTree(TREE_ID_1)
+            .bTree(TREE_ID_2)
+            .renameScore(65)
+            .build();
+    byte[] serialized = Serializer.INSTANCE.serialize(key);
+    assertThat(Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java
new file mode 100644
index 0000000..8d301e4
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java
@@ -0,0 +1,61 @@
+// 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.InternalGroupSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.InternalGroupSerializer.serialize;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.server.util.time.TimeUtil;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class InternalGroupSerializerTest {
+  static final InternalGroup MINIMAL_VALUES_SET =
+      InternalGroup.builder()
+          .setId(AccountGroup.id(123456))
+          .setNameKey(AccountGroup.nameKey("group name"))
+          .setOwnerGroupUUID(AccountGroup.uuid("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+          .setVisibleToAll(false)
+          .setGroupUUID(AccountGroup.uuid("deadbeefdeadbeefdeadbeefdeadbeef12345678"))
+          .setCreatedOn(TimeUtil.nowTs())
+          .setMembers(ImmutableSet.of(Account.id(123), Account.id(321)))
+          .setSubgroups(
+              ImmutableSet.of(
+                  AccountGroup.uuid("87654321deadbeefdeadbeefdeadbeefdeadbeef"),
+                  AccountGroup.uuid("deadbeefdeadbeefdeadbeefdeadbeef87654321")))
+          .build();
+
+  static final InternalGroup ALL_VALUES_SET =
+      MINIMAL_VALUES_SET
+          .toBuilder()
+          .setDescription("description")
+          .setRefState(ObjectId.fromString("12345678deadbeefdeadbeefdeadbeefdeadbeef"))
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    assertThat(deserialize(serialize(MINIMAL_VALUES_SET))).isEqualTo(MINIMAL_VALUES_SET);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
index a82fdb9..ad460cd 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
@@ -38,6 +38,8 @@
           .setCopyAnyScore(!LabelType.DEF_COPY_ANY_SCORE)
           .setCopyMaxScore(!LabelType.DEF_COPY_MAX_SCORE)
           .setCopyMinScore(!LabelType.DEF_COPY_MIN_SCORE)
+          .setCopyAllScoresIfListOfFilesDidNotChange(
+              !LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE)
           .setCopyAllScoresOnMergeFirstParentUpdate(
               !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
           .setCopyAllScoresOnTrivialRebase(!LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
new file mode 100644
index 0000000..b39ba57
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
@@ -0,0 +1,42 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ModifiedFilesCacheKeySerializerTest {
+  private static final ObjectId COMMIT_ID_1 =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId COMMIT_ID_2 =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    ModifiedFilesCacheKey key =
+        ModifiedFilesCacheKey.builder()
+            .project(Project.NameKey.parse("Project/X"))
+            .aCommit(COMMIT_ID_1)
+            .bCommit(COMMIT_ID_2)
+            .renameScore(65)
+            .build();
+    byte[] serialized = ModifiedFilesCacheKey.Serializer.INSTANCE.serialize(key);
+    assertThat(ModifiedFilesCacheKey.Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
new file mode 100644
index 0000000..bff0c5d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
@@ -0,0 +1,56 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl.ValueSerializer;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import java.util.Optional;
+import org.junit.Test;
+
+public class ModifiedFilesSerializerTest {
+  @Test
+  public void roundTrip() {
+    ImmutableList.Builder<ModifiedFile> builder = ImmutableList.builder();
+
+    builder.add(
+        ModifiedFile.builder()
+            .changeType(ChangeType.DELETED)
+            .oldPath(Optional.of("file_1.txt"))
+            .newPath(Optional.of("file_2.txt"))
+            .build());
+    builder.add(
+        ModifiedFile.builder()
+            .changeType(ChangeType.ADDED)
+            .oldPath(Optional.empty())
+            .newPath(Optional.of("file_3.txt"))
+            .build());
+
+    // Note: the default value for strings in protocol buffers is the empty string, hence the
+    // serializer will not be able to differentiate between an empty optional and an optional
+    // with an empty string, i.e. if we serialize an optional with an empty string, the deserialized
+    // object will be an empty optional. That should not be problematic in this case because file
+    // paths cannot be empty anyway.
+
+    ImmutableList<ModifiedFile> modifiedFiles = builder.build();
+
+    byte[] serialized = ValueSerializer.INSTANCE.serialize(modifiedFiles);
+
+    assertThat(ValueSerializer.INSTANCE.deserialize(serialized)).isEqualTo(modifiedFiles);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index e5f3e23..82991b6 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -109,7 +109,8 @@
           });
     }
     LabelType lt =
-        label("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
+        label(
+            LabelId.VERIFIED, value(1, LabelId.VERIFIED), value(0, "No score"), value(-1, "Fails"));
     pc.upsertLabelType(lt);
     save(pc);
   }
@@ -137,12 +138,16 @@
   public void noNormalizeByPermission() throws Exception {
     projectOperations
         .allProjectsForUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
-        .add(allowLabel("Verified").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .add(allowLabel(LabelId.VERIFIED).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
         .update();
 
-    PatchSetApproval cr = psa(userId, "Code-Review", 2);
-    PatchSetApproval v = psa(userId, "Verified", 1);
+    PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 2);
+    PatchSetApproval v = psa(userId, LabelId.VERIFIED, 1);
     assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
   }
 
@@ -150,12 +155,16 @@
   public void normalizeByType() throws Exception {
     projectOperations
         .allProjectsForUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-5, 5))
-        .add(allowLabel("Verified").ref("refs/heads/*").group(REGISTERED_USERS).range(-5, 5))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-5, 5))
+        .add(allowLabel(LabelId.VERIFIED).ref("refs/heads/*").group(REGISTERED_USERS).range(-5, 5))
         .update();
 
-    PatchSetApproval cr = psa(userId, "Code-Review", 5);
-    PatchSetApproval v = psa(userId, "Verified", 5);
+    PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 5);
+    PatchSetApproval v = psa(userId, LabelId.VERIFIED, 5);
     assertEquals(
         Result.create(list(), list(copy(cr, 2), copy(v, 1)), list()),
         norm.normalize(notes, list(cr, v)));
@@ -163,8 +172,8 @@
 
   @Test
   public void emptyPermissionRangeKeepsResult() throws Exception {
-    PatchSetApproval cr = psa(userId, "Code-Review", 1);
-    PatchSetApproval v = psa(userId, "Verified", 1);
+    PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 1);
+    PatchSetApproval v = psa(userId, LabelId.VERIFIED, 1);
     assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
   }
 
@@ -172,11 +181,15 @@
   public void explicitZeroVoteOnNonEmptyRangeIsPresent() throws Exception {
     projectOperations
         .allProjectsForUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
         .update();
 
-    PatchSetApproval cr = psa(userId, "Code-Review", 0);
-    PatchSetApproval v = psa(userId, "Verified", 0);
+    PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 0);
+    PatchSetApproval v = psa(userId, LabelId.VERIFIED, 0);
     assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
   }
 
diff --git a/javatests/com/google/gerrit/server/events/EventJsonTest.java b/javatests/com/google/gerrit/server/events/EventJsonTest.java
index 6163ea7..8e4f436 100644
--- a/javatests/com/google/gerrit/server/events/EventJsonTest.java
+++ b/javatests/com/google/gerrit/server/events/EventJsonTest.java
@@ -52,12 +52,45 @@
 
   private final Gson gson = new EventGsonProvider().get();
 
+  static class CustomEvent extends Event {
+    static String TYPE = "custom-type";
+
+    public String customField;
+
+    protected CustomEvent() {
+      super(TYPE);
+    }
+  }
+
   @Before
   public void setTimeForTesting() {
     TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
   }
 
   @Test
+  public void customEvent() {
+    CustomEvent event = new CustomEvent();
+    event.customField = "customValue";
+    String json = gson.toJson(event);
+    CustomEvent resullt = gson.fromJson(json, CustomEvent.class);
+    assertThat(resullt.type).isEqualTo(CustomEvent.TYPE);
+    assertThat(resullt.customField).isEqualTo(event.customField);
+  }
+
+  @Test
+  public void customEventSimulateClassloaderIssue() {
+    EventTypes.register(CustomEvent.TYPE, CustomEvent.class);
+    CustomEvent event = new CustomEvent();
+    event.customField = "customValue";
+    // Need to serialise using the Event interface instead of json.getClass()
+    // for simulating the serialisation of an object owned by another class loader
+    String json = gson.toJson(event, Event.class);
+    CustomEvent resullt = gson.fromJson(json, CustomEvent.class);
+    assertThat(resullt.type).isEqualTo(CustomEvent.TYPE);
+    assertThat(resullt.customField).isEqualTo(event.customField);
+  }
+
+  @Test
   public void refUpdatedEvent() {
     RefUpdatedEvent event = new RefUpdatedEvent();
 
diff --git a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
index 698acd8..7eb6bc7 100644
--- a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
+++ b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
@@ -60,17 +61,18 @@
   }
 
   @Test
-  public void noCache_tipsFromObjectIdDelegatesToRefDbAndFiltersByPrefix() throws Exception {
+  public void noCache_tipsFromObjectIdDelegatesToRefDb() throws Exception {
     Ref refBla = newRef("refs/bla", "badc0feebadc0feebadc0feebadc0feebadc0fee");
-    Ref refheads = newRef(RefNames.REFS_HEADS, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    String patchSetRef = RefNames.REFS_CHANGES + "01/1/1";
+    Ref patchSet = newRef(patchSetRef, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
 
     RefDatabase mockRefDb = mock(RefDatabase.class);
     ReceivePackRefCache cache = ReceivePackRefCache.noCache(mockRefDb);
     when(mockRefDb.getTipsWithSha1(ObjectId.zeroId()))
-        .thenReturn(ImmutableSet.of(refBla, refheads));
+        .thenReturn(ImmutableSet.of(refBla, patchSet));
 
-    assertThat(cache.tipsFromObjectId(ObjectId.zeroId(), RefNames.REFS_HEADS))
-        .containsExactly(refheads);
+    assertThat(cache.patchSetIdsFromObjectId(ObjectId.zeroId()))
+        .containsExactly(PatchSet.Id.fromRef(patchSetRef));
     verify(mockRefDb).getTipsWithSha1(ObjectId.zeroId());
     verifyNoMoreInteractions(mockRefDb);
   }
@@ -107,25 +109,14 @@
   }
 
   @Test
-  public void advertisedRefs_tipsFromObjectIdWithNoPrefix() throws Exception {
+  public void advertisedRefs_patchSetIdsFromObjectId() throws Exception {
     Map<String, Ref> refs = setupTwoChanges();
     ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
 
     assertThat(
-            cache.tipsFromObjectId(
-                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), null))
-        .containsExactly(refs.get("refs/changes/01/1/1"));
-  }
-
-  @Test
-  public void advertisedRefs_tipsFromObjectIdWithPrefix() throws Exception {
-    Map<String, Ref> refs = setupTwoChanges();
-    ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
-
-    assertThat(
-            cache.tipsFromObjectId(
-                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), "/refs/some"))
-        .isEmpty();
+            cache.patchSetIdsFromObjectId(
+                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee")))
+        .containsExactly(PatchSet.Id.fromRef("refs/changes/01/1/1"));
   }
 
   private static Ref newRef(String name, String sha1) {
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index 20fe387..e24d481 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -20,13 +20,13 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
index bf2ade9..7da4785 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -22,8 +22,8 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroupByIdAudit;
 import com.google.gerrit.entities.AccountGroupMemberAudit;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.account.GroupUuid;
-import com.google.gerrit.server.group.InternalGroup;
 import java.sql.Timestamp;
 import java.util.Set;
 import org.eclipse.jgit.lib.PersonIdent;
diff --git a/javatests/com/google/gerrit/server/group/db/BUILD b/javatests/com/google/gerrit/server/group/db/BUILD
index 3303338..9f9f459 100644
--- a/javatests/com/google/gerrit/server/group/db/BUILD
+++ b/javatests/com/google/gerrit/server/group/db/BUILD
@@ -6,7 +6,6 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/common/data/testing:common-data-test-util",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index c1f3615..4b5d6b2 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -19,8 +19,6 @@
 import static com.google.gerrit.server.group.testing.InternalGroupSubject.internalGroups;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
-import static org.hamcrest.CoreMatchers.instanceOf;
-import static org.hamcrest.MatcherAssert.assertThat;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
@@ -28,11 +26,11 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.testing.InternalGroupSubject;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.truth.OptionalSubject;
@@ -114,8 +112,9 @@
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
-      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      IOException thrown =
+          assertThrows(IOException.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause()).isInstanceOf(ConfigInvalidException.class);
       assertThat(thrown).hasMessageThat().contains("Name of the group " + groupUuid);
     }
   }
@@ -136,8 +135,9 @@
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
-      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      IOException thrown =
+          assertThrows(IOException.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause()).isInstanceOf(ConfigInvalidException.class);
       assertThat(thrown).hasMessageThat().contains("ID of the group " + groupUuid);
     }
   }
@@ -215,8 +215,9 @@
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
-      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      IOException thrown =
+          assertThrows(IOException.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause()).isInstanceOf(ConfigInvalidException.class);
       assertThat(thrown).hasMessageThat().contains("Owner UUID of the group " + groupUuid);
     }
   }
@@ -524,8 +525,9 @@
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
-      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      IOException thrown =
+          assertThrows(IOException.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause()).isInstanceOf(ConfigInvalidException.class);
       assertThat(thrown).hasMessageThat().contains("Name of the group " + groupUuid);
     }
   }
@@ -591,8 +593,9 @@
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
-      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      IOException thrown =
+          assertThrows(IOException.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause()).isInstanceOf(ConfigInvalidException.class);
       assertThat(thrown).hasMessageThat().contains("Owner UUID of the group " + groupUuid);
     }
   }
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 40a6978..a7b25b8 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -24,8 +24,8 @@
 import com.google.common.collect.Table;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -100,8 +100,8 @@
             label(SubmitRecord.Label.Status.MAY, "Label-1", null),
             label(SubmitRecord.Label.Status.OK, "Label-2", 1));
 
-    SubmitRequirement sr =
-        SubmitRequirement.builder()
+    LegacySubmitRequirement sr =
+        LegacySubmitRequirement.builder()
             .setType("short_type")
             .setFallbackText("Fallback text may contain special symbols like < > \\ / ; :")
             .build();
@@ -119,8 +119,11 @@
             label(SubmitRecord.Label.Status.OK, "Label-2", 1));
 
     // Doesn't have any custom data value
-    SubmitRequirement sr =
-        SubmitRequirement.builder().setFallbackText("short_type").setType("ci_status").build();
+    LegacySubmitRequirement sr =
+        LegacySubmitRequirement.builder()
+            .setFallbackText("short_type")
+            .setType("ci_status")
+            .build();
     r.requirements = Collections.singletonList(sr);
 
     assertStoredRecordRoundTrip(r);
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index f5d3bf7..e5b2ffb 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -107,6 +107,32 @@
   }
 
   @Test
+  public void threeLevelTreeWithMultipleSources() throws Exception {
+    Predicate<ChangeData> in = parse("-status:abandoned (foo:a OR file:b)");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isSameInstanceAs(AndChangeSource.class);
+
+    Predicate<ChangeData> firstIndexedSubQuery = parse("-status:abandoned");
+
+    assertThat(out.getChild(0)).isEqualTo(query(firstIndexedSubQuery));
+
+    assertThat(out.getChild(1).getClass()).isSameInstanceAs(OrSource.class);
+    OrSource indexedSubTree = (OrSource) out.getChild(1);
+
+    Predicate<ChangeData> secondIndexedSubQuery = parse("foo:a OR file:b");
+    assertThat(indexedSubTree.getChildren())
+        .containsExactly(
+            query(secondIndexedSubQuery.getChild(1)), secondIndexedSubQuery.getChild(0))
+        .inOrder();
+
+    // Same at the assertions above, that were added for readability
+    assertThat(out.getChild(0)).isEqualTo(query(in.getChild(0)));
+    assertThat(indexedSubTree.getChildren())
+        .containsExactly(query(in.getChild(1).getChild(1)), in.getChild(1).getChild(0))
+        .inOrder();
+  }
+
+  @Test
   public void threeLevelTreeWithSomeIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("-foo:a (file:b OR file:c)");
     Predicate<ChangeData> out = rewrite(in);
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
index 733d784..8d019f3 100644
--- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -24,8 +24,8 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import org.eclipse.jgit.lib.Config;
@@ -76,7 +76,7 @@
       // Create a performance log record.
       TraceContext.newTimer("test").close();
 
-      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      Map<String, ? extends Set<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
       assertThat(tagMap.keySet()).containsExactly("foo");
       assertThat(tagMap.get("foo")).containsExactly("bar");
       assertForceLogging(true);
@@ -90,7 +90,7 @@
               () -> {
                 // Verify that the tags and force logging flag have been propagated to the new
                 // thread.
-                SortedMap<String, SortedSet<Object>> threadTagMap =
+                Map<String, ? extends Set<Object>> threadTagMap =
                     LoggingContext.getInstance().getTags().asMap();
                 expect.that(threadTagMap.keySet()).containsExactly("foo");
                 expect.that(threadTagMap.get("foo")).containsExactly("bar");
diff --git a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
index f6f3b46..200c49d 100644
--- a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
+++ b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
@@ -21,8 +21,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -157,7 +156,7 @@
   }
 
   private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
-    SortedMap<String, SortedSet<Object>> actualTagMap = tags.getTags().asMap();
+    Map<String, ? extends Set<Object>> actualTagMap = tags.getTags().asMap();
     assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
     for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
       assertThat(actualTagMap.get(expectedEntry.getKey()))
diff --git a/javatests/com/google/gerrit/server/logging/TraceContextTest.java b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
index 13f2035..6a3632d 100644
--- a/javatests/com/google/gerrit/server/logging/TraceContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
@@ -21,8 +21,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.server.logging.TraceContext.TraceIdConsumer;
 import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
 import org.junit.After;
 import org.junit.Test;
 
@@ -254,7 +253,7 @@
   }
 
   private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
-    SortedMap<String, SortedSet<Object>> actualTagMap =
+    Map<String, ? extends Set<Object>> actualTagMap =
         LoggingContext.getInstance().getTags().asMap();
     assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
     for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java
new file mode 100644
index 0000000..2ec5e4d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.template.soy.shared.SoyAstCache;
+import java.nio.file.Paths;
+import org.junit.Before;
+import org.junit.Test;
+
+public class MailSoySauceProviderTest {
+
+  private SitePaths sitePaths;
+  private DynamicSet<MailSoyTemplateProvider> set;
+
+  @Before
+  public void setUp() throws Exception {
+    sitePaths = new SitePaths(Paths.get("."));
+    set = new DynamicSet<>();
+  }
+
+  @Test
+  public void soyCompilation() {
+    MailSoySauceProvider provider =
+        new MailSoySauceProvider(
+            sitePaths,
+            new SoyAstCache(),
+            new PluginSetContext<>(set, PluginMetrics.DISABLED_INSTANCE));
+    assertThat(provider.get()).isNotNull(); // should not throw
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index 83c6542..a5cb456 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -45,7 +45,7 @@
 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.EnableReverseDnsLookup;
+import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -153,8 +153,8 @@
                     .annotatedWith(CanonicalWebUrl.class)
                     .toInstance("http://localhost:8080/");
                 bind(Boolean.class)
-                    .annotatedWith(EnableReverseDnsLookup.class)
-                    .toInstance(Boolean.TRUE);
+                    .annotatedWith(EnablePeerIPInReflogRecord.class)
+                    .toInstance(Boolean.FALSE);
                 bind(Realm.class).to(FakeRealm.class);
                 bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
                 bind(AccountCache.class).toInstance(accountCache);
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 6a32fa1..2573a5e 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -167,6 +167,17 @@
             + "Submitted-with: NOT_READY\n"
             + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
             + "Submitted-with: NEED: Alternative-Code-Review\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n"
+            + "Submitted-with: NOT_READY\n"
+            + "Submitted-with: Rule-Name: gerrit~PrologRule\n" // Rule-Name footer is ignored
+            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+            + "Submitted-with: NEED: Code-Review\n");
     assertParseFails("Update change\n\nPatch-set: 1\nSubmitted-with: OOPS\n");
     assertParseFails("Update change\n\nPatch-set: 1\nSubmitted-with: NEED: X+Y\n");
     assertParseFails(
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index dd3238f..5f375ad 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
-import static com.google.gerrit.server.notedb.ChangeNotesState.Serializer.toByteString;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
@@ -33,13 +32,15 @@
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 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.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -332,7 +333,8 @@
             .uploader(Account.id(2000))
             .createdOn(cols.createdOn())
             .build();
-    ByteString ps1Bytes = toByteString(ps1, PatchSetProtoConverter.INSTANCE);
+    Entities.PatchSet ps1Proto = PatchSetProtoConverter.INSTANCE.toProto(ps1);
+    ByteString ps1Bytes = Protos.toByteString(ps1Proto);
     assertThat(ps1Bytes.size()).isEqualTo(66);
 
     PatchSet ps2 =
@@ -342,7 +344,8 @@
             .uploader(Account.id(3000))
             .createdOn(cols.lastUpdatedOn())
             .build();
-    ByteString ps2Bytes = toByteString(ps2, PatchSetProtoConverter.INSTANCE);
+    Entities.PatchSet ps2Proto = PatchSetProtoConverter.INSTANCE.toProto(ps2);
+    ByteString ps2Bytes = Protos.toByteString(ps2Proto);
     assertThat(ps2Bytes.size()).isEqualTo(66);
     assertThat(ps2Bytes).isNotEqualTo(ps1Bytes);
 
@@ -352,8 +355,8 @@
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
             .setColumns(colsProto)
-            .addPatchSet(ps2Bytes)
-            .addPatchSet(ps1Bytes)
+            .addPatchSet(ps2Proto)
+            .addPatchSet(ps1Proto)
             .build());
   }
 
@@ -363,22 +366,23 @@
         PatchSetApproval.builder()
             .key(
                 PatchSetApproval.key(
-                    PatchSet.id(ID, 1), Account.id(2001), LabelId.create("Code-Review")))
+                    PatchSet.id(ID, 1), Account.id(2001), LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .granted(new Timestamp(1212L))
             .build();
-    ByteString a1Bytes = toByteString(a1, PatchSetApprovalProtoConverter.INSTANCE);
-    assertThat(a1Bytes.size()).isEqualTo(43);
+    Entities.PatchSetApproval psa1 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a1);
+    ByteString a1Bytes = Protos.toByteString(psa1);
 
     PatchSetApproval a2 =
         PatchSetApproval.builder()
             .key(
                 PatchSetApproval.key(
-                    PatchSet.id(ID, 1), Account.id(2002), LabelId.create("Verified")))
+                    PatchSet.id(ID, 1), Account.id(2002), LabelId.create(LabelId.VERIFIED)))
             .value(-1)
             .granted(new Timestamp(3434L))
             .build();
-    ByteString a2Bytes = toByteString(a2, PatchSetApprovalProtoConverter.INSTANCE);
+    Entities.PatchSetApproval psa2 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a2);
+    ByteString a2Bytes = Protos.toByteString(psa2);
     assertThat(a2Bytes.size()).isEqualTo(49);
     assertThat(a2Bytes).isNotEqualTo(a1Bytes);
 
@@ -390,8 +394,8 @@
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
             .setColumns(colsProto)
-            .addApproval(a2Bytes)
-            .addApproval(a1Bytes)
+            .addApproval(psa2)
+            .addApproval(psa1)
             .build());
   }
 
@@ -634,6 +638,39 @@
   }
 
   @Test
+  public void serializeAllAttentionSetUpdates() throws Exception {
+    assertRoundTrip(
+        newBuilder()
+            .allAttentionSetUpdates(
+                ImmutableList.of(
+                    AttentionSetUpdate.createFromRead(
+                        Instant.EPOCH.plusSeconds(23),
+                        Account.id(1000),
+                        AttentionSetUpdate.Operation.ADD,
+                        "reason 1"),
+                    AttentionSetUpdate.createFromRead(
+                        Instant.EPOCH.plusSeconds(42),
+                        Account.id(2000),
+                        AttentionSetUpdate.Operation.REMOVE,
+                        "reason 2")))
+            .build(),
+        newProtoBuilder()
+            .addAllAttentionSetUpdate(
+                AttentionSetUpdateProto.newBuilder()
+                    .setTimestampMillis(23_000) // epoch millis
+                    .setAccount(1000)
+                    .setOperation("ADD")
+                    .setReason("reason 1"))
+            .addAllAttentionSetUpdate(
+                AttentionSetUpdateProto.newBuilder()
+                    .setTimestampMillis(42_000) // epoch millis
+                    .setAccount(2000)
+                    .setOperation("REMOVE")
+                    .setReason("reason 2"))
+            .build());
+  }
+
+  @Test
   public void serializeAssigneeUpdates() throws Exception {
     assertRoundTrip(
         newBuilder()
@@ -689,7 +726,8 @@
             Account.id(1000),
             new Timestamp(1212L),
             PatchSet.id(ID, 1));
-    ByteString m1Bytes = toByteString(m1, ChangeMessageProtoConverter.INSTANCE);
+    Entities.ChangeMessage m1Proto = ChangeMessageProtoConverter.INSTANCE.toProto(m1);
+    ByteString m1Bytes = Protos.toByteString(m1Proto);
     assertThat(m1Bytes.size()).isEqualTo(35);
 
     ChangeMessage m2 =
@@ -698,7 +736,8 @@
             Account.id(2000),
             new Timestamp(3434L),
             PatchSet.id(ID, 2));
-    ByteString m2Bytes = toByteString(m2, ChangeMessageProtoConverter.INSTANCE);
+    Entities.ChangeMessage m2Proto = ChangeMessageProtoConverter.INSTANCE.toProto(m2);
+    ByteString m2Bytes = Protos.toByteString(m2Proto);
     assertThat(m2Bytes.size()).isEqualTo(35);
     assertThat(m2Bytes).isNotEqualTo(m1Bytes);
 
@@ -708,8 +747,8 @@
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
             .setColumns(colsProto)
-            .addChangeMessage(m2Bytes)
-            .addChangeMessage(m1Bytes)
+            .addChangeMessage(m2Proto)
+            .addChangeMessage(m1Proto)
             .build());
   }
 
@@ -793,6 +832,9 @@
                     "attentionSet",
                     new TypeLiteral<ImmutableSet<AttentionSetUpdate>>() {}.getType())
                 .put(
+                    "allAttentionSetUpdates",
+                    new TypeLiteral<ImmutableList<AttentionSetUpdate>>() {}.getType())
+                .put(
                     "assigneeUpdates",
                     new TypeLiteral<ImmutableList<AssigneeStatusUpdate>>() {}.getType())
                 .put("submitRecords", new TypeLiteral<ImmutableList<SubmitRecord>>() {}.getType())
@@ -801,6 +843,7 @@
                     "publishedComments",
                     new TypeLiteral<ImmutableListMultimap<ObjectId, HumanComment>>() {}.getType())
                 .put("updateCount", int.class)
+                .put("mergedOn", Timestamp.class)
                 .build());
   }
 
@@ -924,7 +967,7 @@
                 "labels",
                 new TypeLiteral<List<SubmitRecord.Label>>() {}.getType(),
                 "requirements",
-                new TypeLiteral<List<SubmitRequirement>>() {}.getType(),
+                new TypeLiteral<List<LegacySubmitRequirement>>() {}.getType(),
                 "errorMessage",
                 String.class));
     assertThatSerializedClass(SubmitRecord.Label.class)
@@ -933,7 +976,7 @@
                 "label", String.class,
                 "status", SubmitRecord.Label.Status.class,
                 "appliedBy", Account.Id.class));
-    assertThatSerializedClass(SubmitRequirement.class)
+    assertThatSerializedClass(LegacySubmitRequirement.class)
         .hasAutoValueMethods(
             ImmutableMap.of(
                 "fallbackText", String.class,
@@ -941,6 +984,19 @@
   }
 
   @Test
+  public void serializeMergedOn() throws Exception {
+    assertRoundTrip(
+        newBuilder().mergedOn(new Timestamp(234567L)).build(),
+        ChangeNotesStateProto.newBuilder()
+            .setMetaId(SHA_BYTES)
+            .setChangeId(ID.get())
+            .setMergedOnMillis(234567L)
+            .setHasMergedOn(true)
+            .setColumns(colsProto.toBuilder())
+            .build());
+  }
+
+  @Test
   public void changeMessageFields() throws Exception {
     assertThatSerializedClass(ChangeMessage.Key.class)
         .hasAutoValueMethods(ImmutableMap.of("changeId", Change.Id.class, "uuid", String.class));
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 6b9859f4..de49cdf 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.CommentRange;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmissionId;
@@ -153,12 +154,12 @@
     String tag2 = "ip";
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) -1);
+    update.putApproval(LabelId.VERIFIED, (short) -1);
     update.setTag(tag1);
     update.commit();
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) 1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
     update.setTag(tag2);
     update.commit();
 
@@ -177,7 +178,7 @@
     Change c = newChange();
 
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) -1);
+    update.putApproval(LabelId.VERIFIED, (short) -1);
     update.setChangeMessage("integration verification");
     update.setTag(integrationTag);
     update.commit();
@@ -231,8 +232,8 @@
   public void approvalsOnePatchSet() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) 1);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -242,13 +243,13 @@
 
     assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(0).accountId().get()).isEqualTo(1);
-    assertThat(psas.get(0).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(0).value()).isEqualTo((short) -1);
     assertThat(psas.get(0).granted()).isEqualTo(truncate(after(c, 2000)));
 
     assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(1).accountId().get()).isEqualTo(1);
-    assertThat(psas.get(1).label()).isEqualTo("Verified");
+    assertThat(psas.get(1).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(psas.get(1).value()).isEqualTo((short) 1);
     assertThat(psas.get(1).granted()).isEqualTo(psas.get(0).granted());
   }
@@ -257,13 +258,13 @@
   public void approvalsMultiplePatchSets() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.commit();
     PatchSet.Id ps1 = c.currentPatchSetId();
 
     incrementPatchSet(c);
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
     PatchSet.Id ps2 = c.currentPatchSetId();
 
@@ -274,14 +275,14 @@
     PatchSetApproval psa1 = Iterables.getOnlyElement(psas.get(ps1));
     assertThat(psa1.patchSetId()).isEqualTo(ps1);
     assertThat(psa1.accountId().get()).isEqualTo(1);
-    assertThat(psa1.label()).isEqualTo("Code-Review");
+    assertThat(psa1.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa1.value()).isEqualTo((short) -1);
     assertThat(psa1.granted()).isEqualTo(truncate(after(c, 2000)));
 
     PatchSetApproval psa2 = Iterables.getOnlyElement(psas.get(ps2));
     assertThat(psa2.patchSetId()).isEqualTo(ps2);
     assertThat(psa2.accountId().get()).isEqualTo(1);
-    assertThat(psa2.label()).isEqualTo("Code-Review");
+    assertThat(psa2.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa2.value()).isEqualTo((short) +1);
     assertThat(psa2.granted()).isEqualTo(truncate(after(c, 4000)));
   }
@@ -290,22 +291,22 @@
   public void approvalsMultipleApprovals() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
         Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) -1);
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
 
     notes = newNotes(c);
     psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) 1);
   }
 
@@ -313,11 +314,11 @@
   public void approvalsMultipleUsers() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -327,13 +328,13 @@
 
     assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(0).accountId().get()).isEqualTo(1);
-    assertThat(psas.get(0).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(0).value()).isEqualTo((short) -1);
     assertThat(psas.get(0).granted()).isEqualTo(truncate(after(c, 2000)));
 
     assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(1).accountId().get()).isEqualTo(2);
-    assertThat(psas.get(1).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(1).value()).isEqualTo((short) 1);
     assertThat(psas.get(1).granted()).isEqualTo(truncate(after(c, 3000)));
   }
@@ -413,8 +414,8 @@
   public void putOtherUsersApprovals() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
-    update.putApprovalFor(otherUser.getAccountId(), "Code-Review", (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
+    update.putApprovalFor(otherUser.getAccountId(), LabelId.CODE_REVIEW, (short) -1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -425,11 +426,11 @@
     assertThat(approvals).hasSize(2);
 
     assertThat(approvals.get(0).accountId()).isEqualTo(changeOwner.getAccountId());
-    assertThat(approvals.get(0).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(0).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
 
     assertThat(approvals.get(1).accountId()).isEqualTo(otherUser.getAccountId());
-    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo((short) -1);
   }
 
@@ -438,8 +439,8 @@
     Change c = newChange();
     SubmissionId submissionId = new SubmissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
-    update.putApproval("Verified", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
     update.commit();
 
     update = newUpdate(c, changeOwner);
@@ -449,21 +450,21 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Code-Review", "NEED", null))));
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.CODE_REVIEW, "NEED", null))));
     update.commit();
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 2);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
     assertThat(approvals).hasSize(2);
-    assertThat(approvals.get(0).label()).isEqualTo("Verified");
+    assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
     assertThat(approvals.get(0).postSubmit()).isFalse();
-    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo((short) 2);
     assertThat(approvals.get(1).postSubmit()).isTrue();
   }
@@ -473,8 +474,8 @@
     Change c = newChange();
     SubmissionId submissionId = new SubmissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
-    update.putApproval("Verified", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
     update.commit();
 
     Account.Id ownerId = changeOwner.getAccountId();
@@ -486,10 +487,10 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", ownerId),
-                submitLabel("Code-Review", "NEED", null))));
+                submitLabel(LabelId.VERIFIED, "OK", ownerId),
+                submitLabel(LabelId.CODE_REVIEW, "NEED", null))));
     update.putApproval("Other-Label", (short) 1);
-    update.putApprovalFor(ownerId, "Code-Review", (short) 2);
+    update.putApprovalFor(ownerId, LabelId.CODE_REVIEW, (short) 2);
     update.commit();
 
     update = newUpdate(c, otherUser);
@@ -501,11 +502,11 @@
     List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
     assertThat(approvals).hasSize(3);
     assertThat(approvals.get(0).accountId()).isEqualTo(ownerId);
-    assertThat(approvals.get(0).label()).isEqualTo("Verified");
+    assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo(1);
     assertThat(approvals.get(0).postSubmit()).isFalse();
     assertThat(approvals.get(1).accountId()).isEqualTo(ownerId);
-    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo(2);
     assertThat(approvals.get(1).postSubmit()).isFalse(); // During submit.
     assertThat(approvals.get(2).accountId()).isEqualTo(otherId);
@@ -582,11 +583,11 @@
     update.commit();
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -618,12 +619,12 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Code-Review", "NEED", null)),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.CODE_REVIEW, "NEED", null)),
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
                 submitLabel("Alternative-Code-Review", "NEED", null))));
     update.commit();
 
@@ -635,14 +636,14 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Code-Review", "NEED", null)));
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.CODE_REVIEW, "NEED", null)));
     assertThat(recs.get(1))
         .isEqualTo(
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
                 submitLabel("Alternative-Code-Review", "NEED", null)));
     assertThat(notes.getChange().getSubmissionId()).isEqualTo(submissionId.toString());
   }
@@ -656,7 +657,8 @@
     update.merge(
         submissionId,
         ImmutableList.of(
-            submitRecord("OK", null, submitLabel("Code-Review", "OK", otherUser.getAccountId()))));
+            submitRecord(
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", otherUser.getAccountId()))));
     update.commit();
 
     incrementPatchSet(c);
@@ -666,17 +668,88 @@
         submissionId,
         ImmutableList.of(
             submitRecord(
-                "OK", null, submitLabel("Code-Review", "OK", changeOwner.getAccountId()))));
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", changeOwner.getAccountId()))));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getSubmitRecords())
         .containsExactly(
-            submitRecord("OK", null, submitLabel("Code-Review", "OK", changeOwner.getAccountId())));
+            submitRecord(
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", changeOwner.getAccountId())));
     assertThat(notes.getChange().getSubmissionId()).isEqualTo(submissionId.toString());
   }
 
   @Test
+  public void mergedOnEmptyIfNotSubmitted() throws Exception {
+    Change c = newChange();
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    // Make sure unrelevent update does not set mergedOn.
+    update.setTopic("topic");
+    update.commit();
+    assertThat(newNotes(c).getMergedOn()).isEmpty();
+  }
+
+  @Test
+  public void mergedOnSetWhenSubmitted() throws Exception {
+    Change c = newChange();
+
+    SubmissionId submissionId = new SubmissionId(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setSubjectForCommit("Update patch set 1");
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", otherUser.getAccountId()))));
+    update.commit();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getMergedOn()).isPresent();
+    Timestamp mergedOn = notes.getMergedOn().get();
+    assertThat(mergedOn).isEqualTo(notes.getChange().getLastUpdatedOn());
+
+    // Next update does not change mergedOn date.
+    update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getMergedOn().get()).isEqualTo(mergedOn);
+    assertThat(notes.getMergedOn().get()).isLessThan(notes.getChange().getLastUpdatedOn());
+  }
+
+  @Test
+  public void latestMergedOn() throws Exception {
+    Change c = newChange();
+    SubmissionId submissionId = new SubmissionId(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setSubjectForCommit("Update patch set 1");
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", otherUser.getAccountId()))));
+    update.commit();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getMergedOn()).isPresent();
+    Timestamp mergedOn = notes.getMergedOn().get();
+    assertThat(mergedOn).isEqualTo(notes.getChange().getLastUpdatedOn());
+
+    incrementPatchSet(c);
+    update = newUpdate(c, changeOwner);
+    update.setSubjectForCommit("Update patch set 2");
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", changeOwner.getAccountId()))));
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getMergedOn().get()).isGreaterThan(mergedOn);
+    assertThat(notes.getMergedOn().get()).isEqualTo(notes.getChange().getLastUpdatedOn());
+  }
+
+  @Test
   public void emptyChangeUpdate() throws Exception {
     Change c = newChange();
     Ref initial = repo.exactRef(changeMetaRef(c.getId()));
@@ -699,6 +772,13 @@
   }
 
   @Test
+  public void defaultAttentionSetUpdatesIsEmpty() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSetUpdates()).isEmpty();
+  }
+
+  @Test
   public void addAttentionStatus() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -712,6 +792,19 @@
   }
 
   @Test
+  public void addAllAttentionUpdates() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSetUpdates()).containsExactly(addTimestamp(attentionSetUpdate, c));
+  }
+
+  @Test
   public void filterLatestAttentionStatus() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -730,6 +823,28 @@
   }
 
   @Test
+  public void DoesNotFilterLatestAttentionSetUpdates() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate firstAttentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(firstAttentionSetUpdate));
+    update.commit();
+    update = newUpdate(c, changeOwner);
+    firstAttentionSetUpdate = addTimestamp(firstAttentionSetUpdate, c);
+
+    AttentionSetUpdate secondAttentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.REMOVE, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(secondAttentionSetUpdate));
+    update.commit();
+    secondAttentionSetUpdate = addTimestamp(secondAttentionSetUpdate, c);
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSetUpdates())
+        .containsExactly(secondAttentionSetUpdate, firstAttentionSetUpdate);
+  }
+
+  @Test
   public void addAttentionStatus_rejectTimestamp() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -767,6 +882,8 @@
   public void addAttentionStatusForMultipleUsers() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
+    // put the user as cc to ensure that the user took part in this change.
+    update.putReviewer(otherUser.getAccount().id(), CC);
     AttentionSetUpdate attentionSetUpdate0 =
         AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
     AttentionSetUpdate attentionSetUpdate1 =
@@ -1050,7 +1167,7 @@
     assertThat(ts5).isGreaterThan(ts4);
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
     Timestamp ts6 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts6).isGreaterThan(ts5);
@@ -1081,7 +1198,7 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
                 submitLabel("Alternative-Code-Review", "NEED", null))));
     update.commit();
     Timestamp ts10 = newNotes(c).getChange().getLastUpdatedOn();
@@ -1185,7 +1302,7 @@
     RevCommit commit = tr.commit().message("PS2").create();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setCommit(rw, commit);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.setChangeMessage("This is a message");
     update.putComment(
         HumanComment.Status.PUBLISHED,
@@ -1323,10 +1440,10 @@
   public void multipleUpdatesInManager() throws Exception {
     Change c = newChange();
     ChangeUpdate update1 = newUpdate(c, changeOwner);
-    update1.putApproval("Verified", (short) 1);
+    update1.putApproval(LabelId.VERIFIED, (short) 1);
 
     ChangeUpdate update2 = newUpdate(c, otherUser);
-    update2.putApproval("Code-Review", (short) 2);
+    update2.putApproval(LabelId.CODE_REVIEW, (short) 2);
 
     try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
       updateManager.add(update1);
@@ -1339,11 +1456,11 @@
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
-    assertThat(psas.get(0).label()).isEqualTo("Verified");
+    assertThat(psas.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(psas.get(0).value()).isEqualTo((short) 1);
 
     assertThat(psas.get(1).accountId()).isEqualTo(otherUser.getAccount().id());
-    assertThat(psas.get(1).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(1).value()).isEqualTo((short) 2);
   }
 
@@ -1377,7 +1494,7 @@
       updateManager.add(update1);
 
       ChangeUpdate update2 = newUpdate(c, otherUser);
-      update2.putApproval("Code-Review", (short) 2);
+      update2.putApproval(LabelId.CODE_REVIEW, (short) 2);
       updateManager.add(update2);
 
       updateManager.execute();
@@ -1416,11 +1533,11 @@
   public void multipleUpdatesAcrossRefs() throws Exception {
     Change c1 = newChange();
     ChangeUpdate update1 = newUpdate(c1, changeOwner);
-    update1.putApproval("Verified", (short) 1);
+    update1.putApproval(LabelId.VERIFIED, (short) 1);
 
     Change c2 = newChange();
     ChangeUpdate update2 = newUpdate(c2, otherUser);
-    update2.putApproval("Code-Review", (short) 2);
+    update2.putApproval(LabelId.CODE_REVIEW, (short) 2);
 
     Ref initial1 = repo.exactRef(update1.getRefName());
     assertThat(initial1).isNotNull();
@@ -1442,11 +1559,11 @@
 
     PatchSetApproval approval1 =
         newNotes(c1).getApprovals().get(c1.currentPatchSetId()).iterator().next();
-    assertThat(approval1.label()).isEqualTo("Verified");
+    assertThat(approval1.label()).isEqualTo(LabelId.VERIFIED);
 
     PatchSetApproval approval2 =
         newNotes(c2).getApprovals().get(c2.currentPatchSetId()).iterator().next();
-    assertThat(approval2.label()).isEqualTo("Code-Review");
+    assertThat(approval2.label()).isEqualTo(LabelId.CODE_REVIEW);
   }
 
   @Test
@@ -2660,7 +2777,8 @@
 
     ChangeUpdate failingUpdate = newUpdate(c, internalUser);
     assertThrows(
-        IllegalStateException.class, () -> failingUpdate.putApproval("Code-Review", (short) 1));
+        IllegalStateException.class,
+        () -> failingUpdate.putApproval(LabelId.CODE_REVIEW, (short) 1));
   }
 
   @Test
@@ -2822,7 +2940,7 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setPatchSetId(PatchSet.id(c.getId(), c.currentPatchSetId().get() + 1));
     update.setChangeMessage("Should be ignored");
-    update.putApproval("Code-Review", (short) 2);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 2);
     CommentRange range = new CommentRange(1, 1, 2, 1);
     HumanComment comment =
         newComment(
@@ -3179,12 +3297,12 @@
     assertThat(newNotes(c).getUpdateCount()).isEqualTo(1);
 
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.commit();
     assertThat(newNotes(c).getUpdateCount()).isEqualTo(2);
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
     assertThat(newNotes(c).getUpdateCount()).isEqualTo(3);
   }
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 6cfd9f2d..68a1d9d 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -39,8 +40,8 @@
   public void approvalsCommitFormatSimple() throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
     ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
-    update.putApproval("Verified", (short) 1);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.putReviewer(otherUser.getAccount().id(), CC);
     update.commit();
@@ -61,8 +62,7 @@
             + "Reviewer: Gerrit User 1 <1@gerrit>\n"
             + "CC: Gerrit User 2 <2@gerrit>\n"
             + "Label: Code-Review=-1\n"
-            + "Label: Verified=+1\n"
-            + "Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Reviewer was added\"}\n",
+            + "Label: Verified=+1\n",
         commit);
 
     PersonIdent author = commit.getAuthorIdent();
@@ -135,7 +135,7 @@
   public void approvalTombstoneCommitFormat() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.removeApproval("Code-Review");
+    update.removeApproval(LabelId.CODE_REVIEW);
     update.commit();
 
     assertBodyEquals(
@@ -155,12 +155,12 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Code-Review", "NEED", null)),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.CODE_REVIEW, "NEED", null)),
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
                 submitLabel("Alternative-Code-Review", "NEED", null))));
     update.commit();
 
@@ -245,8 +245,7 @@
     update.commit();
 
     assertBodyEquals(
-        "Update patch set 1\n\nPatch-set: 1\nReviewer: Gerrit User 1 <1@gerrit>\n"
-            + "Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Reviewer was added\"}\n",
+        "Update patch set 1\n\nPatch-set: 1\nReviewer: Gerrit User 1 <1@gerrit>\n",
         update.getResult());
   }
 
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
new file mode 100644
index 0000000..5bf5154
--- /dev/null
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import java.io.IOException;
+import java.util.Map;
+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.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TreeFormatter;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test class for diff related logic of {@link DiffOperations}. */
+public class DiffOperationsTest {
+  @Inject private GitRepositoryManager repoManager;
+  @Inject private DiffOperations diffOperations;
+
+  private static final Project.NameKey testProjectName = Project.nameKey("test-project");
+  private Repository repo;
+
+  private final String fileName1 = "file_1.txt";
+  private final String fileContent1 = "File content 1";
+  private final String fileName2 = "file_2.txt";
+  private final String fileContent2 = "File content 2";
+
+  @Before
+  public void setUpInjector() throws Exception {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+    repo = repoManager.createRepository(testProjectName);
+  }
+
+  @Test
+  public void diffModifiedFileAgainstParent() throws Exception {
+    ImmutableMap<String, String> oldFiles =
+        ImmutableMap.of(fileName1, fileContent1, fileName2, fileContent2);
+    ObjectId oldCommitId = createCommit(repo, null, oldFiles);
+
+    ImmutableMap<String, String> newFiles =
+        ImmutableMap.of(fileName1, fileContent1, fileName2, fileContent2 + "\nnew line here");
+    ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
+
+    FileDiffOutput diffOutput =
+        diffOperations.getModifiedFileAgainstParent(
+            testProjectName, newCommitId, /* parentNum=*/ null, fileName2, /* whitespace=*/ null);
+
+    assertThat(diffOutput.oldCommitId()).isEqualTo(oldCommitId);
+    assertThat(diffOutput.newCommitId()).isEqualTo(newCommitId);
+    assertThat(diffOutput.comparisonType().isAgainstParent()).isTrue();
+    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);
+  }
+
+  private static ObjectId createCommitInRepo(
+      Repository repo, ObjectId treeId, ObjectId parentCommit) throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      PersonIdent committer =
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.nowTs());
+      CommitBuilder cb = new CommitBuilder();
+      cb.setTreeId(treeId);
+      cb.setCommitter(committer);
+      cb.setAuthor(committer);
+      cb.setMessage("Test commit");
+      if (parentCommit != null) {
+        cb.setParentIds(parentCommit);
+      }
+      ObjectId commitId = oi.insert(cb);
+      oi.flush();
+      oi.close();
+      return commitId;
+    }
+  }
+
+  private static ObjectId createTree(
+      Repository repo, ImmutableMap<String, String> fileNameToContent) throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = repo.newObjectReader();
+        RevWalk rw = new RevWalk(reader); ) {
+      TreeFormatter formatter = new TreeFormatter();
+      for (Map.Entry<String, String> entry : fileNameToContent.entrySet()) {
+        String fileName = entry.getKey();
+        String fileContent = entry.getValue();
+        ObjectId fileObjId = createBlob(repo, fileContent);
+        formatter.append(fileName, rw.lookupBlob(fileObjId));
+      }
+      ObjectId treeId = oi.insert(formatter);
+      oi.flush();
+      oi.close();
+      return treeId;
+    }
+  }
+
+  private static ObjectId createBlob(Repository repo, String content) throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      ObjectId blobId = oi.insert(Constants.OBJ_BLOB, content.getBytes(UTF_8));
+      oi.flush();
+      oi.close();
+      return blobId;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/patch/PatchListTest.java b/javatests/com/google/gerrit/server/patch/PatchListTest.java
index 56adefa..182ce49 100644
--- a/javatests/com/google/gerrit/server/patch/PatchListTest.java
+++ b/javatests/com/google/gerrit/server/patch/PatchListTest.java
@@ -17,12 +17,15 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.server.patch.PatchList.ChangeTypeCmp;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.InputStream;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 import java.util.Arrays;
+import java.util.List;
 import org.junit.Test;
 
 public class PatchListTest {
@@ -67,6 +70,15 @@
   }
 
   @Test
+  public void changeTypeOrderIsComplete() {
+    List<ChangeType> changeTypeOrder = ChangeTypeCmp.order;
+    ChangeType[] allTypes = ChangeType.values();
+
+    Arrays.sort(allTypes, PatchList.CHANGE_TYPE_CMP);
+    assertThat(changeTypeOrder).containsExactlyElementsIn(allTypes).inOrder();
+  }
+
+  @Test
   public void largeObjectTombstoneCanBeSerializedAndDeserialized() throws Exception {
     // Serialize
     byte[] serializedObject;
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 87db21f..c5bef59 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PermissionRange;
 import com.google.gerrit.entities.Project;
@@ -651,12 +652,13 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(+1, +2))
+        .add(blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/master").group(DEVS).range(+1, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
 
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(2, range);
   }
 
@@ -665,17 +667,18 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
     projectOperations
         .project(parentKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
 
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-1, range);
     assertCanVote(1, range);
     assertCannotVote(-2, range);
@@ -687,18 +690,19 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
     projectOperations
         .project(parentKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
 
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-1, range);
     assertCanVote(1, range);
     assertCannotVote(-2, range);
@@ -832,13 +836,15 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
-        .add(allowLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(-2, 2))
-        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/heads/master"), true)
+        .add(
+            blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/master").group(DEVS).range(-2, 2))
+        .setExclusiveGroup(labelPermissionKey(LabelId.CODE_REVIEW).ref("refs/heads/master"), true)
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-2, range);
   }
 
@@ -1013,12 +1019,17 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, +1))
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(ANONYMOUS_USERS)
+                .range(-1, +1))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
@@ -1028,12 +1039,17 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, +1))
-        .add(allowLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(-2, +2))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(ANONYMOUS_USERS)
+                .range(-1, +1))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/master").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
@@ -1044,12 +1060,16 @@
         .project(localKey)
         .forUpdate()
         .add(
-            blockLabel("Code-Review").ref("refs/heads/master").group(ANONYMOUS_USERS).range(-1, +1))
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/master")
+                .group(ANONYMOUS_USERS)
+                .range(-1, +1))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
@@ -1059,11 +1079,12 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, REGISTERED_USERS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-1, range);
     assertCannotVote(1, range);
   }
@@ -1073,16 +1094,18 @@
     projectOperations
         .project(parentKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
         .update();
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
@@ -1092,12 +1115,12 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
     PermissionRange range =
-        u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review", true);
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW, true);
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
@@ -1107,11 +1130,12 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
@@ -1121,11 +1145,12 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .add(blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
@@ -1135,12 +1160,17 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
@@ -1150,12 +1180,17 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-1, +1))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
@@ -1165,17 +1200,26 @@
     projectOperations
         .project(parentKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, +2))
         .update();
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +1))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, +1))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-1, range);
     assertCannotVote(1, range);
   }
diff --git a/javatests/com/google/gerrit/server/permissions/SectionSortCacheTest.java b/javatests/com/google/gerrit/server/permissions/SectionSortCacheTest.java
new file mode 100644
index 0000000..9ec1625
--- /dev/null
+++ b/javatests/com/google/gerrit/server/permissions/SectionSortCacheTest.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.permissions;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.server.permissions.SectionSortCache.EntryKey;
+import com.google.gerrit.server.permissions.SectionSortCache.EntryVal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test for {@link SectionSortCache} */
+public class SectionSortCacheTest {
+  private SectionSortCache sectionSortCache;
+  private Cache<EntryKey, EntryVal> cache;
+
+  private static final AccessSection sectionA = AccessSection.create("refs/heads/branch_1");
+  private static final AccessSection sectionB = AccessSection.create("refs/base/branch_2");
+  private static final String REF_BASE = "refs/base";
+
+  @Before
+  public void setup() {
+    cache = CacheBuilder.newBuilder().build();
+    sectionSortCache = new SectionSortCache(cache);
+  }
+
+  @Test
+  public void sortSingleElement() {
+    List<AccessSection> input = new ArrayList<>();
+    input.add(sectionA);
+    sectionSortCache.sort(REF_BASE, input);
+    assertThat(input).containsExactly(sectionA);
+  }
+
+  @Test
+  public void sortMultiElements() {
+    List<AccessSection> input = new ArrayList<>();
+    input.add(sectionA);
+    input.add(sectionB);
+    sectionSortCache.sort(REF_BASE, input);
+    assertThat(input).containsExactly(sectionB, sectionA).inOrder();
+  }
+
+  @Test
+  public void sortMultiElementsWhenAlreadyOrdered() {
+    List<AccessSection> input = new ArrayList<>();
+    input.add(sectionB);
+    input.add(sectionA);
+    sectionSortCache.sort(REF_BASE, input);
+    assertThat(input).containsExactly(sectionB, sectionA).inOrder();
+  }
+
+  @Test
+  public void sortMultiElementsWithDuplicates() {
+    AccessSection sectionAClone = sectionA.toBuilder().build();
+    AccessSection sectionBClone = sectionB.toBuilder().build();
+    AccessSection[] input = {sectionBClone, sectionA, sectionAClone, sectionA, sectionB};
+    List<AccessSection> sorted = Arrays.asList(input);
+    sectionSortCache.sort(REF_BASE, sorted);
+    // 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]);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/project/GroupListTest.java b/javatests/com/google/gerrit/server/project/GroupListTest.java
index 7d4b7ca..853507d 100644
--- a/javatests/com/google/gerrit/server/project/GroupListTest.java
+++ b/javatests/com/google/gerrit/server/project/GroupListTest.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.git.ValidationError;
 import java.io.IOException;
 import java.util.Collection;
@@ -57,7 +58,7 @@
     GroupReference groupReference = groupList.byUUID(uuid);
 
     assertEquals(uuid, groupReference.getUUID());
-    assertEquals("Service Users", groupReference.getName());
+    assertEquals(ServiceUserClassifier.SERVICE_USERS, groupReference.getName());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index a39821e..aecb5d3 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -77,6 +77,9 @@
           + "  copyMaxScore = "
           + !LabelType.DEF_COPY_MAX_SCORE
           + "\n"
+          + "  copyAllScoresIfListOfFilesDidNotChange = "
+          + !LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE
+          + "\n"
           + "  copyAllScoresOnMergeFirstParentUpdate = "
           + !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE
           + "\n"
@@ -270,6 +273,8 @@
     assertThat(type.isCopyAnyScore()).isNotEqualTo(LabelType.DEF_COPY_ANY_SCORE);
     assertThat(type.isCopyMinScore()).isNotEqualTo(LabelType.DEF_COPY_MIN_SCORE);
     assertThat(type.isCopyMaxScore()).isNotEqualTo(LabelType.DEF_COPY_MAX_SCORE);
+    assertThat(type.isCopyAllScoresIfListOfFilesDidNotChange())
+        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
     assertThat(type.isCopyAllScoresOnMergeFirstParentUpdate())
         .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
     assertThat(type.isCopyAllScoresOnTrivialRebase())
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 88dda0f..99ccd31 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
@@ -85,6 +86,7 @@
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.lifecycle.LifecycleManager;
@@ -110,6 +112,7 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.index.change.IndexedChangeQuery;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
@@ -719,6 +722,24 @@
   }
 
   @Test
+  public void byParentOf() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    RevCommit commit1 = repo1.parseBody(repo1.commit().message("message").create());
+    Change change1 = insert(repo1, newChangeForCommit(repo1, commit1));
+    RevCommit commit2 = repo1.parseBody(repo1.commit(commit1));
+    Change change2 = insert(repo1, newChangeForCommit(repo1, commit2));
+    RevCommit commit3 = repo1.parseBody(repo1.commit(commit1, commit2));
+    Change change3 = insert(repo1, newChangeForCommit(repo1, commit3));
+
+    assertQuery("parentof:" + change1.getId().get());
+    assertQuery("parentof:" + change1.getKey().get());
+    assertQuery("parentof:" + change2.getId().get(), change1);
+    assertQuery("parentof:" + change2.getKey().get(), change1);
+    assertQuery("parentof:" + change3.getId().get(), change2, change1);
+    assertQuery("parentof:" + change3.getKey().get(), change2, change1);
+  }
+
+  @Test
   public void byParentProject() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
     TestRepository<Repo> repo2 = createProject("repo2", "repo1");
@@ -1023,7 +1044,7 @@
         Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
 
     LabelType verified =
-        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       ProjectConfig cfg = projectConfigFactory.create(project);
       cfg.load(md);
@@ -1039,7 +1060,7 @@
         .add(allowLabel(verified.getName()).ref(heads).group(REGISTERED_USERS).range(-1, 1))
         .update();
 
-    ReviewInput reviewVerified = new ReviewInput().label("Verified", 1);
+    ReviewInput reviewVerified = new ReviewInput().label(LabelId.VERIFIED, 1);
     ChangeInserter ins = newChange(repo);
     ChangeInserter ins2 = newChange(repo);
     ChangeInserter ins3 = newChange(repo);
@@ -1590,6 +1611,10 @@
     assertThat(nowMs - lastUpdatedMs(change2)).isEqualTo(thirtyHoursInMs);
     assertThat(TimeUtil.nowMs()).isEqualTo(nowMs);
 
+    // Change1 was last updated on 2009-09-30 21:00:00 -0000
+    // Change2 was last updated on 2009-10-02 03:00:00 -0000
+    // The endpoint is 2009-10-03 09:00:00 -0000
+
     assertQuery("-age:1d");
     assertQuery("-age:" + (30 * 60 - 1) + "m");
     assertQuery("-age:2d", change2);
@@ -1597,6 +1622,15 @@
     assertQuery("age:3d");
     assertQuery("age:2d", change1);
     assertQuery("age:1d", change2, change1);
+
+    // Same test as above, but using filter code path.
+    assertQuery(makeIndexedPredicateFilterQuery("-age:1d"));
+    assertQuery(makeIndexedPredicateFilterQuery("-age:" + (30 * 60 - 1) + "m"));
+    assertQuery(makeIndexedPredicateFilterQuery("-age:2d"), change2);
+    assertQuery(makeIndexedPredicateFilterQuery("-age:3d"), change2, change1);
+    assertQuery(makeIndexedPredicateFilterQuery("age:3d"));
+    assertQuery(makeIndexedPredicateFilterQuery("age:2d"), change1);
+    assertQuery(makeIndexedPredicateFilterQuery("age:1d"), change2, change1);
   }
 
   @Test
@@ -1609,6 +1643,9 @@
     Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
+    // Change1 was last updated on 2009-09-30 21:00:00 -0000
+    // Change2 was last updated on 2009-10-02 03:00:00 -0000
+
     for (String predicate : Lists.newArrayList("before:", "until:")) {
       assertQuery(predicate + "2009-09-29");
       assertQuery(predicate + "2009-09-30");
@@ -1621,6 +1658,22 @@
       assertQuery(predicate + "2009-10-01", change1);
       assertQuery(predicate + "2009-10-03", change2, change1);
     }
+
+    // Same test as above, but using filter code path.
+    for (String predicate : Lists.newArrayList("before:", "until:")) {
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-09-29"));
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-09-30"));
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "\"2009-09-30 16:59:00 -0400\""));
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "\"2009-09-30 20:59:00 -0000\""));
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "\"2009-09-30 20:59:00\""));
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-09-30 17:02:00 -0400\""), change1);
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-10-01 21:02:00 -0000\""), change1);
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "\"2009-10-01 21:02:00\""), change1);
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-10-01"), change1);
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-10-03"), change2, change1);
+    }
   }
 
   @Test
@@ -1633,6 +1686,8 @@
     Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
+    // Change1 was last updated on 2009-09-30 21:00:00 -0000
+    // Change2 was last updated on 2009-10-02 03:00:00 -0000
     for (String predicate : Lists.newArrayList("after:", "since:")) {
       assertQuery(predicate + "2009-10-03");
       assertQuery(predicate + "\"2009-10-01 20:59:59 -0400\"", change2);
@@ -1640,6 +1695,197 @@
       assertQuery(predicate + "2009-10-01", change2);
       assertQuery(predicate + "2009-09-30", change2, change1);
     }
+
+    // Same test as above, but using filter code path.
+    for (String predicate : Lists.newArrayList("after:", "since:")) {
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-10-03"));
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-10-01 20:59:59 -0400\""), change2);
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-10-01 20:59:59 -0000\""), change2);
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-10-01"), change2);
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-09-30"), change2, change1);
+    }
+  }
+
+  @Test
+  public void mergedOperatorSupportedByIndexVersion() throws Exception {
+    if (getSchemaVersion() < 61) {
+      assertMissingField(ChangeField.MERGED_ON);
+      assertFailingQuery(
+          "mergedbefore:2009-10-01",
+          "'mergedbefore' operator is not supported by change index version");
+      assertFailingQuery(
+          "mergedafter:2009-10-01",
+          "'mergedafter' operator is not supported by change index version");
+    } else {
+      assertThat(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    }
+  }
+
+  @Test
+  public void byMergedBefore() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
+
+    // Stop the clock, will set time to specific test values.
+    resetTimeWithClockStep(0, MILLISECONDS);
+    TestRepository<Repo> repo = createProject("repo");
+    long startMs = TestTimeUtil.START.toEpochMilli();
+    TestTimeUtil.setClock(new Timestamp(startMs));
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    Change change3 = insert(repo, newChange(repo));
+
+    TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
+    submit(change3);
+    TestTimeUtil.setClock(new Timestamp(startMs + 2 * thirtyHoursInMs));
+    submit(change2);
+    TestTimeUtil.setClock(new Timestamp(startMs + 3 * thirtyHoursInMs));
+    // Put another approval on the change, just to update it.
+    approve(change1);
+    approve(change3);
+
+    assertThat(TimeUtil.nowMs()).isEqualTo(startMs + 3 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+
+    // Verify that:
+    // 1. Change1 was not submitted and should be never returned.
+    // 2. Change2 was merged on 2009-10-02 03:00:00 -0000
+    // 3. Change3 was merged on 2009-10-03 09:00:00.0 -0000
+    assertQuery("mergedbefore:2009-10-01");
+    // Changes excluded on the date submitted.
+    assertQuery("mergedbefore:2009-10-02");
+    assertQuery("mergedbefore:\"2009-10-01 22:59:00 -0400\"");
+    assertQuery("mergedbefore:\"2009-10-01 02:59:00\"");
+    assertQuery("mergedbefore:\"2009-10-01 23:02:00 -0400\"", change3);
+    assertQuery("mergedbefore:\"2009-10-02 03:02:00 -0000\"", change3);
+    assertQuery("mergedbefore:\"2009-10-02 03:02:00\"", change3);
+    assertQuery("mergedbefore:2009-10-03", change3);
+    // Changes are sorted by lastUpdatedOn first, then by mergedOn.
+    // Even though Change2 was merged after Change3, Change3 is returned first.
+    assertQuery("mergedbefore:2009-10-04", change3, change2);
+
+    // Same test as above, but using filter code path.
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-01"));
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-02"));
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-01 22:59:00 -0400\""));
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-01 02:59:00\""));
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-01 23:02:00 -0400\""), change3);
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-02 03:02:00 -0000\""), change3);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-02 03:02:00\""), change3);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-03"), change3);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-04"), change3, change2);
+  }
+
+  @Test
+  public void byMergedAfter() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
+
+    // Stop the clock, will set time to specific test values.
+    resetTimeWithClockStep(0, MILLISECONDS);
+    TestRepository<Repo> repo = createProject("repo");
+    long startMs = TestTimeUtil.START.toEpochMilli();
+    TestTimeUtil.setClock(new Timestamp(startMs));
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    Change change3 = insert(repo, newChange(repo));
+    assertThat(TimeUtil.nowMs()).isEqualTo(startMs);
+
+    TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
+    submit(change3);
+
+    TestTimeUtil.setClock(new Timestamp(startMs + 2 * thirtyHoursInMs));
+    submit(change2);
+
+    TestTimeUtil.setClock(new Timestamp(startMs + 3 * thirtyHoursInMs));
+    // Put another approval on the change, just to update it.
+    approve(change1);
+    approve(change3);
+
+    assertThat(TimeUtil.nowMs()).isEqualTo(startMs + 3 * thirtyHoursInMs);
+
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+
+    // Verify that:
+    // 1. Change1 was not submitted and should be never returned.
+    // 2. Change2 was merged on 2009-10-02 03:00:00 -0000
+    // 3. Change3 was merged on 2009-10-03 09:00:00.0 -0000
+    assertQuery("mergedafter:2009-10-01", change3, change2);
+    // Changes are sorted by lastUpdatedOn first, then by mergedOn.
+    // Even though Change2 was merged after Change3, Change3 is returned first.
+    assertQuery("mergedafter:\"2009-10-01 22:59:00 -0400\"", change3, change2);
+    assertQuery("mergedafter:\"2009-10-02 02:59:00 -0000\"", change3, change2);
+    assertQuery("mergedafter:\"2009-10-01 23:02:00 -0400\"", change2);
+    assertQuery("mergedafter:\"2009-10-02 03:02:00 -0000\"", change2);
+    // Changes included on the date submitted.
+    assertQuery("mergedafter:2009-10-02", change3, change2);
+    assertQuery("mergedafter:2009-10-03", change2);
+
+    // Same test as above, but using filter code path.
+
+    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-01"), change3, change2);
+    // Changes are sorted by lastUpdatedOn first, then by mergedOn.
+    // Even though Change2 was merged after Change3, Change3 is returned first.
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-01 22:59:00 -0400\""),
+        change3,
+        change2);
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-02 02:59:00 -0000\""),
+        change3,
+        change2);
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-01 23:02:00 -0400\""), change2);
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-02 03:02:00 -0000\""), change2);
+    // Changes included on the date submitted.
+    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-02"), change3, change2);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-03"), change2);
+  }
+
+  @Test
+  public void updatedThenMergedOrder() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
+
+    // Stop the clock, will set time to specific test values.
+    resetTimeWithClockStep(0, MILLISECONDS);
+    TestRepository<Repo> repo = createProject("repo");
+    long startMs = TestTimeUtil.START.toEpochMilli();
+    TestTimeUtil.setClock(new Timestamp(startMs));
+
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    Change change3 = insert(repo, newChange(repo));
+
+    TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
+    submit(change2);
+    submit(change3);
+    TestTimeUtil.setClock(new Timestamp(startMs + 2 * thirtyHoursInMs));
+    // Approve post submit just to update lastUpdatedOn
+    approve(change3);
+    approve(change2);
+    submit(change1);
+
+    // All Changes were last updated at the same time.
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+
+    // Changes are sorted by lastUpdatedOn first, then by mergedOn, then by Id in reverse order.
+    // 1. Change3 and Change2 were merged at the same time, but Change3 ID > Change2 ID.
+    // 2. Change1 ID < Change3 ID & Change2 ID but it was merged last.
+    assertQuery("mergedbefore:2009-10-06", change1, change3, change2);
+    assertQuery("mergedafter:2009-09-30", change1, change3, change2);
+    assertQuery("status:merged", change1, change3, change2);
   }
 
   @Test
@@ -2141,21 +2387,24 @@
     TestRepository<Repo> repo = createProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
     RevCommit commit2 = repo.parseBody(repo.commit().add("file1", "contents2").create());
+    RevCommit commit3 =
+        repo.parseBody(repo.commit().parent(commit2).add("file1", "contents3").create());
     Change change1 = insert(repo, newChangeForCommit(repo, commit1));
     Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
     RevCommit mergeCommit =
         repo.branch("master")
             .commit()
             .message("Merge commit")
             .parent(commit1)
-            .parent(commit2)
+            .parent(commit3)
             .insertChangeId()
             .create();
     Change mergeChange = insert(repo, newChangeForCommit(repo, mergeCommit));
 
     assertQuery("status:open is:merge", mergeChange);
-    assertQuery("status:open -is:merge", change2, change1);
-    assertQuery("status:open", mergeChange, change2, change1);
+    assertQuery("status:open -is:merge", change3, change2, change1);
+    assertQuery("status:open", mergeChange, change3, change2, change1);
   }
 
   @Test
@@ -3072,6 +3321,14 @@
     gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
     Account.Id user2Id =
         accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+
+    // Add the second user as cc to ensure that user took part of the change and can be added to the
+    // attention set.
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.reviewer = user2Id.toString();
+    addReviewerInput.state = ReviewerState.CC;
+    gApi.changes().id(change.getChangeId()).addReviewer(addReviewerInput);
+
     input = new AttentionSetInput(user2Id.toString(), "reason 2");
     gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
 
@@ -3102,6 +3359,7 @@
     assertQuery("-assignee:" + user.getUserName().get(), change2);
   }
 
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
   @Test
   public void userDestination() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
@@ -3113,6 +3371,8 @@
         .hasMessageThat()
         .isEqualTo("Unknown named destination: foo");
 
+    Account.Id anotherUserId =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     String destination1 = "refs/heads/master\trepo1";
     String destination2 = "refs/heads/master\trepo2";
     String destination3 = "refs/heads/master\trepo1\nrefs/heads/master\trepo2";
@@ -3128,8 +3388,32 @@
       allUsers.branch(refsUsers).commit().add("destinations/destination4", destination4).create();
       allUsers.branch(refsUsers).commit().add("destinations/destination5", destination5).create();
 
+      String anotherRefsUsers = RefNames.refsUsers(anotherUserId);
+      allUsers
+          .branch(anotherRefsUsers)
+          .commit()
+          .add("destinations/destination6", destination1)
+          .create();
+      allUsers
+          .branch(anotherRefsUsers)
+          .commit()
+          .add("destinations/destination7", destination2)
+          .create();
+      allUsers
+          .branch(anotherRefsUsers)
+          .commit()
+          .add("destinations/destination8", destination3)
+          .create();
+      allUsers
+          .branch(anotherRefsUsers)
+          .commit()
+          .add("destinations/destination9", destination4)
+          .create();
+
       Ref userRef = allUsers.getRepository().exactRef(refsUsers);
+      Ref anotherUserRef = allUsers.getRepository().exactRef(anotherRefsUsers);
       assertThat(userRef).isNotNull();
+      assertThat(anotherUserRef).isNotNull();
     }
 
     assertQuery("destination:destination1", change1);
@@ -3137,38 +3421,87 @@
     assertQuery("destination:destination3", change2, change1);
     assertQuery("destination:destination4");
     assertQuery("destination:destination5");
+    assertQuery("destination:destination6,user=" + anotherUserId, change1);
+    assertQuery("destination:name=destination6,user=" + anotherUserId, change1);
+    assertQuery("destination:user=" + anotherUserId + ",destination7", change2);
+    assertQuery("destination:user=" + anotherUserId + ",name=destination8", change2, change1);
+    assertQuery("destination:destination9,user=" + anotherUserId);
+
+    assertThatQueryException("destination:destination3,user=" + anotherUserId)
+        .hasMessageThat()
+        .isEqualTo("Unknown named destination: destination3");
+    assertThatQueryException("destination:destination3,user=test")
+        .hasMessageThat()
+        .isEqualTo("Account 'test' not found");
+
+    requestContext.setContext(newRequestContext(anotherUserId));
+    // account 1000000 is not visible to 'anotheruser' as they are not an admin
+    assertThatQueryException("destination:destination3,user=" + userId)
+        .hasMessageThat()
+        .isEqualTo("Account '1000000' not found");
   }
 
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
   @Test
   public void userQuery() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChangeForBranch(repo, "stable"));
 
+    Account.Id anotherUserId =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     String queryListText =
         "query1\tproject:repo\n"
             + "query2\tproject:repo status:open\n"
             + "query3\tproject:repo branch:stable\n"
             + "query4\tproject:repo branch:other";
+    String anotherQueryListText =
+        "query5\tproject:repo\n"
+            + "query6\tproject:repo status:merged\n"
+            + "query7\tproject:repo branch:stable\n"
+            + "query8\tproject:repo branch:other";
 
     try (TestRepository<Repo> allUsers =
             new TestRepository<>(repoManager.openRepository(allUsersName));
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsersName)) {
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsersName);
+        MetaDataUpdate anotherMd = metaDataUpdateFactory.create(allUsersName)) {
       VersionedAccountQueries queries = VersionedAccountQueries.forUser(userId);
       queries.load(md);
       queries.setQueryList(queryListText);
       queries.commit(md);
+      VersionedAccountQueries anotherQueries = VersionedAccountQueries.forUser(anotherUserId);
+      anotherQueries.load(anotherMd);
+      anotherQueries.setQueryList(anotherQueryListText);
+      anotherQueries.commit(anotherMd);
     }
 
     assertThatQueryException("query:foo").hasMessageThat().isEqualTo("Unknown named query: foo");
+    assertThatQueryException("query:query1,user=" + anotherUserId)
+        .hasMessageThat()
+        .isEqualTo("Unknown named query: query1");
+    assertThatQueryException("query:query1,user=test")
+        .hasMessageThat()
+        .isEqualTo("Account 'test' not found");
+
+    requestContext.setContext(newRequestContext(anotherUserId));
+    // account 1000000 is not visible to 'anotheruser' as they are not an admin
+    assertThatQueryException("query:query1,user=" + userId)
+        .hasMessageThat()
+        .isEqualTo("Account '1000000' not found");
+    requestContext.setContext(newRequestContext(userId));
 
     assertQuery("query:query1", change2, change1);
     assertQuery("query:query2", change2, change1);
+    assertQuery("query:name=query5,user=" + anotherUserId, change2, change1);
+    assertQuery("query:user=" + anotherUserId + ",name=query6");
     gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(change1.getChangeId()).current().submit();
     assertQuery("query:query2", change2);
     assertQuery("query:query3", change2);
     assertQuery("query:query4");
+    assertQuery("query:query6,user=" + anotherUserId, change1);
+    assertQuery("query:user=" + anotherUserId + ",query7", change2);
+    assertQuery("query:query8,user=" + anotherUserId);
   }
 
   @Test
@@ -3522,6 +3855,62 @@
     return c.getLastUpdatedOn().getTime();
   }
 
+  // Get the last  updated time from ChangeApi
+  protected long lastUpdatedMsApi(Change c) throws Exception {
+    return gApi.changes().id(c.getChangeId()).get().updated.getTime();
+  }
+
+  protected void approve(Change change) throws Exception {
+    gApi.changes().id(change.getChangeId()).current().review(ReviewInput.approve());
+  }
+
+  protected void submit(Change change) throws Exception {
+    approve(change);
+    gApi.changes().id(change.getChangeId()).current().submit();
+  }
+
+  /**
+   * Generates a search query to test {@link com.google.gerrit.index.query.Matchable} implementation
+   * of change {@link IndexPredicate}
+   *
+   * <p>This code path requires triggering the condition, when
+   *
+   * <ol>
+   *   <li>The query is rewritten into multiple {@link IndexedChangeQuery} by {@link
+   *       com.google.gerrit.server.index.change.ChangeIndexRewriter#rewrite}
+   *   <li>The changes are returned from the index by the first {@link IndexedChangeQuery}
+   *   <li>Then constrained in {@link com.google.gerrit.index.query.AndSource#match} by applying all
+   *       parsed predicates from the search query
+   *   <li>Thus, the rest of {@link IndexedChangeQuery} work as filters on the index results, see
+   *       {@link IndexedChangeQuery#match}
+   * </ol>
+   *
+   * The constructed query only constrains by the passed searchTerm for the operator that is being
+   * tested (for all changes without a reviewer):
+   *
+   * <ul>
+   *   <li>The search term 'status:new OR status:merged OR status:abandoned' is used to return all
+   *       changes from the search index.
+   *   <li>The non-indexed search term 'reviewerin:"Empty Group"' is only used to make the right AND
+   *       operand work as a filter (not a data source).
+   *   <li>See how it is rewritten in {@link
+   *       com.google.gerrit.server.index.change.ChangeIndexRewriterTest#threeLevelTreeWithMultipleSources}
+   * </ul>
+   *
+   * @param searchTerm change search term that maps to {@link IndexPredicate} and needs to be tested
+   *     as filter
+   * @return a search query that allows to test the {@code searchTerm} as a filter.
+   */
+  protected String makeIndexedPredicateFilterQuery(String searchTerm) throws Exception {
+    String emptyGroupName = "Empty Group";
+    if (gApi.groups().query(emptyGroupName).get().isEmpty()) {
+      createGroup(emptyGroupName, "Administrators");
+    }
+    String queryPattern =
+        "(status:new OR status:merged OR status:abandoned) AND (reviewerin:\"%s\" OR %s)";
+    return String.format(queryPattern, emptyGroupName, searchTerm);
+  }
+
   private void addComment(int changeId, String message, Boolean unresolved) throws Exception {
     ReviewInput input = new ReviewInput();
     ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index 0258e5d..43b9690 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -19,7 +19,6 @@
         "//java/com/google/gerrit/acceptance/config",
         "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/httpd",
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index d80eac0..d760003 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -24,6 +24,7 @@
 import com.google.common.base.CharMatcher;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -47,7 +48,6 @@
 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.InternalGroup;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.index.group.GroupField;
diff --git a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
index d0398e9..ebb2f38 100644
--- a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.ComparisonType;
@@ -40,6 +41,7 @@
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.restapi.change.CommentPorter.Metrics;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import java.sql.Timestamp;
 import java.util.Arrays;
@@ -61,6 +63,8 @@
   @Mock private PatchListCache patchListCache;
   @Mock private CommentsUtil commentsUtil;
 
+  private static final CommentPorter.Metrics metrics = new Metrics(new DisabledMetricMaker());
+
   private int uuidCounter = 0;
 
   @Test
@@ -72,7 +76,7 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
@@ -94,7 +98,7 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
@@ -116,7 +120,7 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenThrow(IllegalStateException.class);
@@ -136,7 +140,7 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
@@ -161,7 +165,7 @@
     PatchSet patchset3 = createPatchset(PatchSet.id(changeId, 3));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2, patchset3);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     // Place the comments on different patchsets to have two different diff requests.
     HumanComment comment1 = createComment(patchset1.id(), "myFile");
     HumanComment comment2 = createComment(patchset2.id(), "myFile");
@@ -191,7 +195,7 @@
     // Leave out patchset 1 (e.g. reserved for draft patchsets in the past).
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index 5cefe74..b3e0c56 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -25,6 +25,7 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Set;
 import java.util.stream.Collectors;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -33,7 +34,6 @@
 @RunWith(JUnit4.class)
 public class ListChangeCommentsTest {
 
-  @SuppressWarnings("TruthIncompatibleType")
   @Test
   public void commentsLinkedToChangeMessagesIgnoreGerritAutoGenTaggedMessages() {
     /* Comments should not be linked to Gerrit's autogenerated messages */
@@ -55,10 +55,10 @@
         .isEqualTo(getChangeMessage(changeMessages, "cm3").getKey().uuid());
 
     // Make sure no comment is linked to the auto-gen message
-    assertThat(comments.stream().map(c -> c.changeMessageId).collect(Collectors.toSet()))
-        .doesNotContain(
-            /* expected: String, actual: ChangeMessage */ getChangeMessage(
-                changeMessages, "cmAutoGenByGerrit"));
+    Set<String> changeMessageIds =
+        comments.stream().map(c -> c.changeMessageId).collect(Collectors.toSet());
+    assertThat(changeMessageIds)
+        .doesNotContain(getChangeMessage(changeMessages, "cmAutoGenByGerrit").getKey().uuid());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/rules/BUILD b/javatests/com/google/gerrit/server/rules/BUILD
index 250b0ce..5c57ede 100644
--- a/javatests/com/google/gerrit/server/rules/BUILD
+++ b/javatests/com/google/gerrit/server/rules/BUILD
@@ -7,7 +7,6 @@
     resources = ["//prologtests:gerrit_common_test"],
     runtime_deps = ["//prolog:gerrit-prolog-common"],
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
diff --git a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
index cf5e8fe..e5dd817 100644
--- a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
+++ b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
@@ -34,12 +34,12 @@
 public class IgnoreSelfApprovalRuleTest {
   private static final Change.Id CHANGE_ID = Change.id(100);
   private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
-  private static final LabelType VERIFIED = makeLabel("Verified");
+  private static final LabelType VERIFIED = makeLabel(LabelId.VERIFIED);
   private static final Account.Id USER1 = makeAccount(100001);
 
   @Test
   public void filtersByLabel() {
-    LabelType codeReview = makeLabel("Code-Review");
+    LabelType codeReview = makeLabel(LabelId.CODE_REVIEW);
     PatchSetApproval approvalVerified = makeApproval(VERIFIED.getLabelId(), USER1, 2);
     PatchSetApproval approvalCr = makeApproval(codeReview.getLabelId(), USER1, 2);
 
diff --git a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java b/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
index 8622b32..a5357e1 100644
--- a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
+++ b/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
@@ -16,13 +16,14 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.gerrit.entities.LabelId;
 import org.junit.Test;
 
 public class PrologRuleEvaluatorTest {
 
   @Test
   public void validLabelNamesAreKept() {
-    for (String labelName : new String[] {"Verified", "Code-Review"}) {
+    for (String labelName : new String[] {LabelId.VERIFIED, LabelId.CODE_REVIEW}) {
       assertThat(PrologRuleEvaluator.checkLabelName(labelName)).isEqualTo(labelName);
     }
   }
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index d6c5b5a..e6a6497 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.GroupUuid;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.Sequences;
@@ -88,7 +89,7 @@
     expectedConfig.fromText(getDefaultAllProjectsWithAllDefaultSections());
 
     GroupReference adminsGroup = createGroupReference("Administrators");
-    GroupReference batchUsersGroup = createGroupReference("Service Users");
+    GroupReference batchUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
     AllProjectsInput allProjectsInput =
         AllProjectsInput.builder()
             .administratorsGroup(adminsGroup)
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index 01a44f3..fc6b412 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -82,7 +83,7 @@
 
   @Test
   public void groupIsCreatedWhenSchemaIsCreated() throws Exception {
-    assertThat(hasGroup("Service Users")).isTrue();
+    assertThat(hasGroup(ServiceUserClassifier.SERVICE_USERS)).isTrue();
     assertThat(hasGroup("Non-Interactive Users")).isFalse();
   }
 
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index e175b95..4fe4ab04 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -10,7 +10,6 @@
     ],
     deps = [
         "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
diff --git a/lib/BUILD b/lib/BUILD
index 0110047..660b041 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -54,6 +54,16 @@
 )
 
 java_library(
+    name = "jgit-ssh-apache",
+    data = ["//lib:LICENSE-jgit"],
+    visibility = ["//visibility:public"],
+    exports = ["@jgit//org.eclipse.jgit.ssh.apache:ssh-apache"],
+    runtime_deps = [
+        "//lib/mina:sshd-sftp",
+    ],
+)
+
+java_library(
     name = "jgit-archive",
     data = ["//lib:LICENSE-jgit"],
     visibility = ["//visibility:public"],
@@ -480,17 +490,17 @@
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
     visibility = ["//visibility:public"],
     exports = [
-        ":hamcrest-core",
+        ":hamcrest",
         "@junit//jar",
     ],
-    runtime_deps = [":hamcrest-core"],
+    runtime_deps = [":hamcrest"],
 )
 
 java_library(
-    name = "hamcrest-core",
+    name = "hamcrest",
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
     visibility = ["//visibility:public"],
-    exports = ["@hamcrest-core//jar"],
+    exports = ["@hamcrest//jar"],
 )
 
 java_library(
diff --git a/lib/LICENSE-PublicDomain b/lib/LICENSE-PublicDomain
new file mode 100644
index 0000000..8a71ce0
--- /dev/null
+++ b/lib/LICENSE-PublicDomain
@@ -0,0 +1 @@
+This software has been placed in the public domain by its author(s).
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index 1da7f50..18b9b91 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -27,6 +27,21 @@
     ],
 )
 
+java_plugin(
+    name = "auto-value-gson-plugin",
+    processor_class = "com.ryanharter.auto.value.gson.factory.AutoValueGsonAdapterFactoryProcessor",
+    deps = [
+        "@auto-value-annotations//jar",
+        "@auto-value-gson-extension//jar",
+        "@auto-value-gson-factory//jar",
+        "@auto-value-gson-runtime//jar",
+        "@auto-value//jar",
+        "@autotransient//jar",
+        "@gson//jar",
+        "@javapoet//jar",
+    ],
+)
+
 java_library(
     name = "auto-value",
     data = ["//lib:LICENSE-Apache2.0"],
@@ -50,3 +65,17 @@
     visibility = ["//visibility:public"],
     exports = ["@auto-value-annotations//jar"],
 )
+
+java_library(
+    name = "auto-value-gson",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exported_plugins = [
+        ":auto-value-gson-plugin",
+    ],
+    visibility = ["//visibility:public"],
+    exports = [
+        "@auto-value-gson-extension//jar",
+        "@auto-value-gson-factory//jar",
+        "@auto-value-gson-runtime//jar",
+    ],
+)
diff --git a/lib/guava.bzl b/lib/guava.bzl
deleted file mode 100644
index 4de39cb..0000000
--- a/lib/guava.bzl
+++ /dev/null
@@ -1,5 +0,0 @@
-GUAVA_VERSION = "29.0-jre"
-
-GUAVA_BIN_SHA1 = "801142b4c3d0f0770dd29abea50906cacfddd447"
-
-GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
diff --git a/lib/guice/BUILD b/lib/guice/BUILD
index 14179d6..f73984b 100644
--- a/lib/guice/BUILD
+++ b/lib/guice/BUILD
@@ -1,9 +1,4 @@
-load("@rules_java//java:defs.bzl", "java_import", "java_library")
-
-java_import(
-    name = "guice-library-no-aop",
-    jars = ["@guice-library-no-aop//file"],
-)
+load("@rules_java//java:defs.bzl", "java_library")
 
 java_library(
     name = "guice",
@@ -19,7 +14,8 @@
     name = "guice-library",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = [":guice-library-no-aop"],
+    exports = ["@guice-library//jar"],
+    runtime_deps = ["aopalliance"],
 )
 
 java_library(
@@ -39,6 +35,12 @@
 )
 
 java_library(
+    name = "aopalliance",
+    data = ["//lib:LICENSE-PublicDomain"],
+    exports = ["@aopalliance//jar"],
+)
+
+java_library(
     name = "javax_inject",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
diff --git a/lib/mina/BUILD b/lib/mina/BUILD
index 70e7c1d..3f23263 100644
--- a/lib/mina/BUILD
+++ b/lib/mina/BUILD
@@ -13,6 +13,13 @@
 )
 
 java_library(
+    name = "sshd-sftp",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@sshd-sftp//jar"],
+)
+
+java_library(
     name = "eddsa",
     data = ["//lib:LICENSE-CC0-1.0"],
     visibility = ["//visibility:public"],
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 13e81f7..ec8e018 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -18,13 +18,14 @@
 dropwizard-core
 duct-tape
 eddsa
-elasticsearch-rest-client
 flogger
 flogger-log4j-backend
 flogger-system-backend
+guava
 guice-assistedinject
-guice-library-no-aop
+guice-library
 guice-servlet
+hamcrest
 httpasyncclient
 httpcore-nio
 impl-log4j
@@ -32,6 +33,7 @@
 jackson-annotations
 jackson-core
 jcl-over-slf4j
+jimfs
 jna
 jruby
 log-api
@@ -41,10 +43,15 @@
 nekohtml
 objenesis
 openid-consumer
+soy
 sshd-mina
 sshd-osgi
+sshd-sftp
 testcontainers
-testcontainers-elasticsearch
+truth
+truth-java8-extension
+truth-liteproto-extension
+truth-proto-extension
 tukaani-xz
 visible-assertions
 xerces
diff --git a/modules/jgit b/modules/jgit
index 8470771..78c9b92 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 84707715108a65a366ef35f2ae04aabecd0b35f6
+Subproject commit 78c9b9260a5287d09c87b407e396021590714513
diff --git a/package.json b/package.json
index c75795d..f52aa74 100644
--- a/package.json
+++ b/package.json
@@ -2,24 +2,27 @@
   "name": "gerrit",
   "version": "3.1.0-SNAPSHOT",
   "description": "Gerrit Code Review",
-  "dependencies": {},
-  "devDependencies": {
+  "dependencies": {
     "@bazel/concatjs": "^5.1.0",
     "@bazel/rollup": "^5.1.0",
     "@bazel/terser": "^5.1.0",
-    "@bazel/typescript": "^5.1.0",
-    "eslint": "^6.6.0",
-    "eslint-config-google": "^0.13.0",
-    "eslint-plugin-html": "^6.0.0",
-    "eslint-plugin-import": "^2.20.1",
-    "eslint-plugin-jsdoc": "^19.2.0",
-    "eslint-plugin-prettier": "^3.1.3",
-    "gts": "^2.0.2",
+    "@bazel/typescript": "^5.1.0"
+  },
+  "devDependencies": {
+    "@typescript-eslint/eslint-plugin": "^4.22.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-node": "^11.1.0",
+    "eslint-plugin-prettier": "^3.4.0",
+    "gts": "^3.1.0",
     "polymer-cli": "^1.9.11",
-    "prettier": "2.0.5",
-    "rollup": "^2.3.4",
-    "terser": "^4.8.0",
-    "typescript": "3.9.5"
+    "prettier": "2.2.1",
+    "rollup": "^2.45.2",
+    "terser": "^5.6.1",
+    "typescript": "4.1.4"
   },
   "scripts": {
     "clean": "git clean -fdx && bazel clean --expunge",
@@ -31,8 +34,8 @@
     "eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
     "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
     "polylint": "npm run safe_bazelisk test polygerrit-ui/app:polylint_test",
-    "test:debug": "npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --browsers ChromeDev --no-single-run --testFiles",
-    "test:single": "npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --testFiles"
+    "test:debug": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --browsers ChromeDev --no-single-run --testFiles",
+    "test:single": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --testFiles"
   },
   "repository": {
     "type": "git",
diff --git a/plugins/BUILD b/plugins/BUILD
index 943471a..dd0be66 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -50,6 +50,7 @@
     "//java/com/google/gerrit/server/cache/mem",
     "//java/com/google/gerrit/server/cache/serialize",
     "//java/com/google/gerrit/server/data",
+    "//java/com/google/gerrit/server/git/receive",
     "//java/com/google/gerrit/server/logging",
     "//java/com/google/gerrit/server/schema",
     "//java/com/google/gerrit/server/util/time",
@@ -58,6 +59,7 @@
     "//java/com/google/gerrit/util/logging",
     "//lib/antlr:java-runtime",
     "//lib/auto:auto-value-annotations",
+    "//lib/auto:auto-value-gson",
     "//lib/commons:compress",
     "//lib/commons:dbcp",
     "//lib/commons:lang",
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index c621796..aa2edb4 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit c6217963e42322accc3f0bacb6540f8791f67ab0
+Subproject commit aa2edb431337cc935659ab69d95ca7555cbc787e
diff --git a/plugins/delete-project b/plugins/delete-project
index 7d060da..142875a 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 7d060dab5f311ec498f9a3b0aa58ce797b8c4d28
+Subproject commit 142875ae29b728e4fbad5bc22dc132df37cc4de7
diff --git a/plugins/download-commands b/plugins/download-commands
index 87e3930..5bd359c 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 87e3930cea7c06aea454998abdddf6515a9f103b
+Subproject commit 5bd359c08e10b93d2c08762f75cde01a14e45fc6
diff --git a/plugins/gitiles b/plugins/gitiles
index d83dc8b..5e8809c 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit d83dc8b0cd4b56a769b4c6ce909ba9c3a79aa2a1
+Subproject commit 5e8809c34798022a81cabe1d2d682bb83245a3c7
diff --git a/plugins/replication b/plugins/replication
index fb4854b..b4ebcfc 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit fb4854b57300f1060690b59722eae8c3a18cdab5
+Subproject commit b4ebcfced53c1f4d4e9bcd885978c276fc55070f
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index 30ca9c1..09623b9 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit 30ca9c1fa624b7389703dd8f8d35cff778e60d83
+Subproject commit 09623b9432d360060f88ae48fb3386e374ca29c0
diff --git a/plugins/webhooks b/plugins/webhooks
index 83dda66..dba493b 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit 83dda664bf0ad96249dfd99a03b11d2eaf32703d
+Subproject commit dba493b1679cc07b0f5e3fd9277b306c4693e08a
diff --git a/polygerrit-ui/FE_Style_Guide.md b/polygerrit-ui/FE_Style_Guide.md
index c9a5d9b..a636119 100644
--- a/polygerrit-ui/FE_Style_Guide.md
+++ b/polygerrit-ui/FE_Style_Guide.md
@@ -10,11 +10,20 @@
 Additionally to the rules above, Gerrit frontend uses the following rules (some of them have automated checks,
 some don't):
 
+- [Prefer null over undefined](#prefer-null)
 - [Use destructuring imports only](#destructuring-imports-only)
 - [Use classes and services for storing and manipulating global state](#services-for-global-state)
 - [Pass required services in the constructor for plain classes](#pass-dependencies-in-constructor)
 - [Assign required services in a HTML/Polymer element constructor](#assign-dependencies-in-html-element-constructor)
 
+## <a name="prefer-undefined"></a>Prefer `undefined` over `null`
+
+It is more confusing than helpful to work with both `null` and `undefined`. We prefer to only use `undefined` in
+our code base. Try to avoid `null`.
+
+Some browser and library APIs are using `null`, so we cannot remove `null` completely from our code base. But even
+then try to convert return values and leak as few `nulls` as possible.
+
 ## <a name="destructuring-imports-only"></a>Use destructuring imports only
 Always use destructuring import statement and specify all required names explicitly (e.g. `import {a,b,c} from '...'`)
 where possible.
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 41ee468..c6e6cd9 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -116,7 +116,7 @@
 the command line:
 
 ```sh
-./polygerrit-ui/run-server.sh --plugins=plugins/my_plugin/static/my_plugin.js,plugins/my_plugin/static/my_plugin.html
+./polygerrit-ui/run-server.sh --plugins=plugins/my_plugin/static/my_plugin.js
 ```
 
 If any issues occured, please refer to the Troubleshooting section at the bottom or contact the team!
@@ -195,6 +195,9 @@
 npm run test:debug async-foreach-behavior_test.js
 ```
 
+When converting a test file to typescript, the command for running tests is
+still using the .js suffix and not the new .ts suffix.
+
 Commands `test:debug` and `test:single` assumes that compiled code is located
 in the `./ts-out/polygerrit-ui/app` directory. It's up to you how to achieve it.
 For example, the following options are possible:
@@ -301,7 +304,7 @@
 existing helpers to create an object with all required properties:
 ```
 // Before:
-sinon.stub(element.$.restAPI, 'getPreferences').returns(
+sinon.stub(element.restApiService, 'getPreferences').returns(
     Promise.resolve({default_diff_view: 'UNIFIED'}));
 
 // After:
@@ -431,12 +434,10 @@
     .stub(element, '_reload')
     .callsFake(() => Promise.resolve());
 
-stub('gr-rest-api-interface', {
-  getDiffComments() { return Promise.resolve({}); },
-  getDiffRobotComments() { return Promise.resolve({}); },
-  getDiffDrafts() { return Promise.resolve({}); },
-  _fetchSharedCacheURL() { return Promise.resolve({}); },
-});
+stubRestApi('getDiffComments').returns(Promise.resolve({}));
+stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+stubRestApi('_fetchSharedCacheURL').returns(Promise.resolve({}));
 ```
 
 In such cases, validate the input and output of a stub/fake method. Quite often
@@ -448,61 +449,9 @@
   // GrChangeView._reload method returns an array
   .callsFake(() => Promise.resolve([])); // return [] here
 
-stub('gr-rest-api-interface', {
   ...
   // Fix return type:
-  _fetchSharedCacheURL() { return Promise.resolve({} as ParsedJSON); },
-});
-```
-
-If a method has multiple overloads, you can use one of 2 options:
-```
-// Option 1: less accurate, but shorter:
-function getCommentsStub() {
-  return Promise.resolve({});
-}
-
-stub('gr-rest-api-interface', {
-  ...
-  getDiffComments: (getCommentsStub as unknown) as RestApiService['getDiffComments'],
-  getDiffRobotComments: (getCommentsStub as unknown) as RestApiService['getDiffRobotComments'],
-  getDiffDrafts: (getCommentsStub as unknown) as RestApiService['getDiffDrafts'],
-  ...
-});
-
-// Option 2: more accurate, but longer.
-// Step 1: define the same overloads for stub:
-function getDiffCommentsStub(
-  changeNum: NumericChangeId
-): Promise<PathToCommentsInfoMap | undefined>;
-function getDiffCommentsStub(
-  changeNum: NumericChangeId,
-  basePatchNum: PatchSetNum,
-  patchNum: PatchSetNum,
-  path: string
-): Promise<GetDiffCommentsOutput>;
-
-// Step 2: implement stub method for differnt input
-function getDiffCommentsStub(
-  _: NumericChangeId,
-  basePatchNum?: PatchSetNum,
-):
-  | Promise<PathToCommentsInfoMap | undefined>
-  | Promise<GetDiffCommentsOutput> {
-  if (basePatchNum) {
-    return Promise.resolve({
-      baseComments: [],
-      comments: [],
-    });
-  }
-  return Promise.resolve({});
-}
-
-// Step 3: use stubbed function:
-stub('gr-rest-api-interface', {
-  ...
-  getDiffComments: getDiffCommentsStub,
-  ...
+  stubRestApi('_fetchSharedCacheURL').returns(Promise.resolve({} as ParsedJSON));
 });
 ```
 
diff --git a/polygerrit-ui/app/.eslintignore b/polygerrit-ui/app/.eslintignore
index 16ea228..087a049 100644
--- a/polygerrit-ui/app/.eslintignore
+++ b/polygerrit-ui/app/.eslintignore
@@ -1,3 +1,4 @@
 **/node_modules
 **/rollup.config.js
 node_modules_licenses
+!.eslintrc-bazel.js
diff --git a/polygerrit-ui/app/.eslintrc-bazel.js b/polygerrit-ui/app/.eslintrc-bazel.js
index 977eb45..9a51242 100644
--- a/polygerrit-ui/app/.eslintrc-bazel.js
+++ b/polygerrit-ui/app/.eslintrc-bazel.js
@@ -20,7 +20,7 @@
 // for node_modules.
 
 function getBazelSettings() {
-  const runFilesDir = process.env["RUNFILES_DIR"];
+  const runFilesDir = process.env['RUNFILES_DIR'];
   if (!runFilesDir) {
     // eslint is executed with 'bazel run ...' to fix the source code. It runs
     // against real source code, no special paths for node_modules is set.
@@ -28,18 +28,18 @@
   }
   // eslint is executed with 'bazel test...'. Set path to required node_modules
   return {
-    "import/resolver": {
-      "node": {
-        "paths": [
+    'import/resolver': {
+      node: {
+        paths: [
           `${runFilesDir}/ui_npm/node_modules`,
-          `${runFilesDir}/ui_dev_npm/node_modules`
-        ]
-      }
-    }
+          `${runFilesDir}/ui_dev_npm/node_modules`,
+        ],
+      },
+    },
   };
 }
 
 module.exports = {
-  "extends": "./.eslintrc.js",
-  "settings": getBazelSettings(),
+  extends: './.eslintrc.js',
+  settings: getBazelSettings(),
 };
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index c5dde38..faf126c 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -20,219 +20,225 @@
 const path = require('path');
 
 module.exports = {
-  "extends": ["eslint:recommended", "google"],
-  "parserOptions": {
-    "ecmaVersion": 9,
-    "sourceType": "module"
+  extends: ['eslint:recommended', 'google'],
+  parserOptions: {
+    ecmaVersion: 9,
+    sourceType: 'module',
   },
-  "env": {
-    "browser": true,
-    "es6": true
+  env: {
+    browser: true,
+    es6: true,
   },
-  "rules": {
+  rules: {
     // https://eslint.org/docs/rules/no-confusing-arrow
-    "no-confusing-arrow": "error",
+    'no-confusing-arrow': 'error',
     // https://eslint.org/docs/rules/newline-per-chained-call
-    "newline-per-chained-call": ["error", {"ignoreChainWithDepth": 2}],
+    'newline-per-chained-call': ['error', {ignoreChainWithDepth: 2}],
     // https://eslint.org/docs/rules/arrow-body-style
-    "arrow-body-style": ["error", "as-needed",
-      {"requireReturnForObjectLiteral": true}],
+    'arrow-body-style': ['error', 'as-needed',
+      {requireReturnForObjectLiteral: true}],
     // https://eslint.org/docs/rules/arrow-parens
-    "arrow-parens": ["error", "as-needed"],
+    'arrow-parens': ['error', 'as-needed'],
     // https://eslint.org/docs/rules/block-spacing
-    "block-spacing": ["error", "always"],
+    'block-spacing': ['error', 'always'],
     // https://eslint.org/docs/rules/brace-style
-    "brace-style": ["error", "1tbs", {"allowSingleLine": true}],
+    'brace-style': ['error', '1tbs', {allowSingleLine: true}],
     // https://eslint.org/docs/rules/camelcase
-    "camelcase": "off",
+    'camelcase': 'off',
     // https://eslint.org/docs/rules/comma-dangle
-    "comma-dangle": ["error", {
-      "arrays": "always-multiline",
-      "objects": "always-multiline",
-      "imports": "always-multiline",
-      "exports": "always-multiline",
-      "functions": "never"
+    'comma-dangle': ['error', {
+      arrays: 'always-multiline',
+      objects: 'always-multiline',
+      imports: 'always-multiline',
+      exports: 'always-multiline',
+      functions: 'never',
     }],
     // https://eslint.org/docs/rules/eol-last
-    "eol-last": "off",
+    'eol-last': 'off',
+    'guard-for-in': 'error',
     // https://eslint.org/docs/rules/indent
-    "indent": ["error", 2, {
-      "MemberExpression": 2,
-      "FunctionDeclaration": {"body": 1, "parameters": 2},
-      "FunctionExpression": {"body": 1, "parameters": 2},
-      "CallExpression": {"arguments": 2},
-      "ArrayExpression": 1,
-      "ObjectExpression": 1,
-      "SwitchCase": 1
+    'indent': ['error', 2, {
+      MemberExpression: 2,
+      FunctionDeclaration: {body: 1, parameters: 2},
+      FunctionExpression: {body: 1, parameters: 2},
+      CallExpression: {arguments: 2},
+      ArrayExpression: 1,
+      ObjectExpression: 1,
+      SwitchCase: 1,
     }],
     // https://eslint.org/docs/rules/keyword-spacing
-    "keyword-spacing": ["error", {"after": true, "before": true}],
+    'keyword-spacing': ['error', {after: true, before: true}],
     // https://eslint.org/docs/rules/lines-between-class-members
-    "lines-between-class-members": ["error", "always"],
+    'lines-between-class-members': ['error', 'always'],
     // https://eslint.org/docs/rules/max-len
-    "max-len": [
-      "error",
+    'max-len': [
+      'error',
       80,
       2,
       {
-        "ignoreComments": true,
-        "ignorePattern": "^import .*;$"
-      }
+        ignoreComments: true,
+        ignorePattern: '^import .*;$',
+      },
     ],
     // https://eslint.org/docs/rules/new-cap
-    "new-cap": ["error", {
-      "capIsNewExceptions": ["Polymer", "GestureEventListeners"],
-      "capIsNewExceptionPattern": "^.*Mixin$"
+    'new-cap': ['error', {
+      capIsNewExceptions: ['Polymer'],
+      capIsNewExceptionPattern: '^.*Mixin$',
     }],
     // https://eslint.org/docs/rules/no-console
-    "no-console": ["error", { allow: ["warn", "error", "info", "assert", "group", "groupEnd"] }],
+    'no-console': [
+      'error',
+      {allow: ['warn', 'error', 'info', 'assert', 'group', 'groupEnd']},
+    ],
     // https://eslint.org/docs/rules/no-multiple-empty-lines
-    "no-multiple-empty-lines": ["error", {"max": 1}],
+    'no-multiple-empty-lines': ['error', {max: 1}],
     // https://eslint.org/docs/rules/no-prototype-builtins
-    "no-prototype-builtins": "off",
+    'no-prototype-builtins': 'off',
     // https://eslint.org/docs/rules/no-redeclare
-    "no-redeclare": "off",
+    'no-redeclare': 'off',
     // https://eslint.org/docs/rules/no-trailing-spaces
-    "no-trailing-spaces": "error",
+    'no-trailing-spaces': 'error',
     // https://eslint.org/docs/rules/no-irregular-whitespace
-    "no-irregular-whitespace": "error",
+    'no-irregular-whitespace': 'error',
     // https://eslint.org/docs/rules/array-callback-return
-    "array-callback-return": ['error', { allowImplicit: true }],
+    'array-callback-return': ['error', {allowImplicit: true}],
     // https://eslint.org/docs/rules/no-restricted-syntax
-    "no-restricted-syntax": [
-      "error",
+    'no-restricted-syntax': [
+      'error',
       {
-        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='test'][property.name='only']",
-        "message": "Remove test.only."
+        selector: 'ExpressionStatement > CallExpression > ' +
+            'MemberExpression[object.name=\'test\'][property.name=\'only\']',
+        message: 'Remove test.only.',
       },
       {
-        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='suite'][property.name='only']",
-        "message": "Remove suite.only."
-      }
+        selector: 'ExpressionStatement > CallExpression > ' +
+            'MemberExpression[object.name=\'suite\'][property.name=\'only\']',
+        message: 'Remove suite.only.',
+      },
     ],
     // no-undef disables global variable.
     // "globals" declares allowed global variables.
     // https://eslint.org/docs/rules/no-undef
-    "no-undef": ["error"],
+    'no-undef': ['error'],
     // https://eslint.org/docs/rules/no-useless-escape
-    "no-useless-escape": "off",
+    'no-useless-escape': 'off',
     // https://eslint.org/docs/rules/no-var
-    "no-var": "error",
+    'no-var': 'error',
     // https://eslint.org/docs/rules/operator-linebreak
-    "operator-linebreak": "off",
+    'operator-linebreak': 'off',
     // https://eslint.org/docs/rules/object-shorthand
-    "object-shorthand": ["error", "always"],
+    'object-shorthand': ['error', 'always'],
     // https://eslint.org/docs/rules/padding-line-between-statements
-    "padding-line-between-statements": [
-      "error",
+    'padding-line-between-statements': [
+      'error',
       {
-        "blankLine": "always",
-        "prev": "class",
-        "next": "*"
+        blankLine: 'always',
+        prev: 'class',
+        next: '*',
       },
       {
-        "blankLine": "always",
-        "prev": "*",
-        "next": "class"
-      }
+        blankLine: 'always',
+        prev: '*',
+        next: 'class',
+      },
     ],
     // https://eslint.org/docs/rules/prefer-arrow-callback
-    "prefer-arrow-callback": "error",
+    'prefer-arrow-callback': 'error',
     // https://eslint.org/docs/rules/prefer-const
-    "prefer-const": "error",
+    'prefer-const': 'error',
     // https://eslint.org/docs/rules/prefer-promise-reject-errors
-    "prefer-promise-reject-errors": "error",
+    'prefer-promise-reject-errors': 'error',
     // https://eslint.org/docs/rules/prefer-spread
-    "prefer-spread": "error",
+    'prefer-spread': 'error',
     // https://eslint.org/docs/rules/prefer-object-spread
-    "prefer-object-spread": "error",
+    'prefer-object-spread': 'error',
     // https://eslint.org/docs/rules/quote-props
-    "quote-props": ["error", "consistent-as-needed"],
+    'quote-props': ['error', 'consistent-as-needed'],
     // https://eslint.org/docs/rules/semi
-    "semi": ["error", "always"],
+    'semi': ['error', 'always'],
     // https://eslint.org/docs/rules/template-curly-spacing
-    "template-curly-spacing": "error",
+    'template-curly-spacing': 'error',
 
     // https://eslint.org/docs/rules/require-jsdoc
-    "require-jsdoc": 0,
+    'require-jsdoc': 0,
     // https://eslint.org/docs/rules/valid-jsdoc
-    "valid-jsdoc": 0,
+    'valid-jsdoc': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-alignment
-    "jsdoc/check-alignment": 2,
+    'jsdoc/check-alignment': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-examples
-    "jsdoc/check-examples": 0,
+    'jsdoc/check-examples': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-indentation
-    "jsdoc/check-indentation": 0,
+    'jsdoc/check-indentation': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-param-names
-    "jsdoc/check-param-names": 0,
+    'jsdoc/check-param-names': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-syntax
-    "jsdoc/check-syntax": 0,
+    'jsdoc/check-syntax': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-tag-names
-    "jsdoc/check-tag-names": 0,
+    'jsdoc/check-tag-names': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-types
-    "jsdoc/check-types": 0,
+    'jsdoc/check-types': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-implements-on-classes
-    "jsdoc/implements-on-classes": 2,
+    'jsdoc/implements-on-classes': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-match-description
-    "jsdoc/match-description": 0,
+    'jsdoc/match-description': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-newline-after-description
-    "jsdoc/newline-after-description": 2,
+    'jsdoc/newline-after-description': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-types
-    "jsdoc/no-types": 0,
+    'jsdoc/no-types': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-undefined-types
-    "jsdoc/no-undefined-types": 0,
+    'jsdoc/no-undefined-types': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-description
-    "jsdoc/require-description": 0,
+    'jsdoc/require-description': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-description-complete-sentence
-    "jsdoc/require-description-complete-sentence": 0,
+    'jsdoc/require-description-complete-sentence': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-example
-    "jsdoc/require-example": 0,
+    'jsdoc/require-example': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-hyphen-before-param-description
-    "jsdoc/require-hyphen-before-param-description": 0,
+    'jsdoc/require-hyphen-before-param-description': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-jsdoc
-    "jsdoc/require-jsdoc": 0,
+    'jsdoc/require-jsdoc': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param
-    "jsdoc/require-param": 0,
+    'jsdoc/require-param': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-description
-    "jsdoc/require-param-description": 0,
+    'jsdoc/require-param-description': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-name
-    "jsdoc/require-param-name": 2,
+    'jsdoc/require-param-name': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns
-    "jsdoc/require-returns": 0,
+    'jsdoc/require-returns': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-check
-    "jsdoc/require-returns-check": 0,
+    'jsdoc/require-returns-check': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-description
-    "jsdoc/require-returns-description": 0,
+    'jsdoc/require-returns-description': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-valid-types
-    "jsdoc/valid-types": 2,
+    'jsdoc/valid-types': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-file-overview
-    "jsdoc/require-file-overview": ["error", {
-      "tags": {
-        "license": {
-          "mustExist": true,
-          "preventDuplicates": true
-        }
-      }
+    'jsdoc/require-file-overview': ['error', {
+      tags: {
+        license: {
+          mustExist: true,
+          preventDuplicates: true,
+        },
+      },
     }],
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-self-import.md
-    "import/no-self-import": 2,
+    'import/no-self-import': 2,
     // The no-cycle rule is slow, because it doesn't cache dependencies.
     // Disable it.
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-cycle.md
-    "import/no-cycle": 0,
+    'import/no-cycle': 0,
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-useless-path-segments.md
-    "import/no-useless-path-segments": 2,
+    'import/no-useless-path-segments': 2,
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-unused-modules.md
-    "import/no-unused-modules": 2,
+    'import/no-unused-modules': 2,
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-default-export.md
-    "import/no-default-export": 2,
+    'import/no-default-export': 2,
     // Prevents certain identifiers being used.
     // Prefer flush() over flushAsynchronousOperations().
-    "id-blacklist": ["error", "flushAsynchronousOperations"],
+    'id-blacklist': ['error', 'flushAsynchronousOperations'],
   },
 
   // List of allowed globals in all files
-  "globals": {
+  globals: {
     // Polygerrit global variables.
     // You must not add anything new in this list!
     // Instead export variables from modules
@@ -240,150 +246,171 @@
     // Global variables from 3rd party libraries.
     // You should not add anything in this list, always try to import
     // If import is not possible - you can extend this list
-    "ShadyCSS": "readonly",
-    "linkify": "readonly",
-    "security": "readonly",
+    ShadyCSS: 'readonly',
+    linkify: 'readonly',
+    security: 'readonly',
   },
-  "overrides": [
+  overrides: [
     {
-      // .js-only rules
-      "files": ["**/*.js"],
-      "rules": {
-        // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-type
-        "jsdoc/require-param-type": 2,
-        // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-type
-        "jsdoc/require-returns-type": 2,
-        // The rule is required for .js files only, because typescript compiler
-        // always checks import.
-        "import/no-unresolved": 2,
-        "import/named": 2,
+      files: ['.eslintrc.js', '.eslintrc-bazel.js'],
+      env: {
+        browser: false,
+        es6: true,
+        node: true,
       },
-      "globals": {
-        "goog": "readonly",
-      }
     },
     {
-      "files": ["**/*.ts"],
-      "extends": [require.resolve("gts/.eslintrc.json")],
-      "rules": {
-        "no-restricted-imports": ["error", {
-          name: "@polymer/decorators/lib/decorators",
-          message: "Use @polymer/decorators instead",
+      // .js-only rules
+      files: ['**/*.js'],
+      rules: {
+        // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-type
+        'jsdoc/require-param-type': 2,
+        // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-type
+        'jsdoc/require-returns-type': 2,
+        // The rule is required for .js files only, because typescript compiler
+        // always checks import.
+        'import/no-unresolved': 2,
+        'import/named': 2,
+      },
+      globals: {
+        goog: 'readonly',
+      },
+    },
+    {
+      files: ['**/*.ts'],
+      extends: [require.resolve('gts/.eslintrc.json')],
+      rules: {
+        'no-restricted-imports': ['error', {
+          name: '@polymer/decorators/lib/decorators',
+          message: 'Use @polymer/decorators instead',
         }],
+        '@typescript-eslint/no-explicit-any': 'error',
         // See https://github.com/GoogleChromeLabs/shadow-selection-polyfill/issues/9
-        "@typescript-eslint/ban-ts-ignore": "off",
+        '@typescript-eslint/ban-ts-comment': 'off',
         // The following rules is required to match internal google rules
-        "@typescript-eslint/restrict-plus-operands": "error",
+        '@typescript-eslint/restrict-plus-operands': 'error',
+        '@typescript-eslint/no-unused-vars': [
+          'error',
+          {argsIgnorePattern: '^_'},
+        ],
         // https://github.com/mysticatea/eslint-plugin-node/blob/master/docs/rules/no-unsupported-features/node-builtins.md
-        "node/no-unsupported-features/node-builtins": "off",
+        'node/no-unsupported-features/node-builtins': 'off',
         // Disable no-invalid-this for ts files, because it incorrectly reports
         // errors in some cases (see https://github.com/typescript-eslint/typescript-eslint/issues/491)
         // At the same time, we are using typescript in a strict mode and
         // it catches almost all errors related to invalid usage of this.
-        "no-invalid-this": "off",
+        'no-invalid-this': 'off',
 
-        "node/no-extraneous-import": "off",
+        'node/no-extraneous-import': 'off',
 
         // Typescript already checks for undef
-        "no-undef": "off",
+        'no-undef': 'off',
 
-        "jsdoc/no-types": 2,
+        'jsdoc/no-types': 2,
       },
-      "parserOptions": {
-        "project": path.resolve(__dirname, "./tsconfig_eslint.json"),
-      }
-    },
-    {
-      "files": ["*.html", "test.js", "test-infra.js"],
-      "rules": {
-        "jsdoc/require-file-overview": "off"
+      parserOptions: {
+        project: path.resolve(__dirname, './tsconfig_eslint.json'),
       },
     },
     {
-      "files": [
-        "*.html",
-        "*_test.js",
-        "a11y-test-utils.js",
+      files: [
+        '*_test.ts',
+        'test-utils.ts',
+      ],
+      rules: {
+        '@typescript-eslint/no-explicit-any': 'off',
+      },
+    },
+    {
+      files: ['*.html', 'test.js', 'test-infra.js'],
+      rules: {
+        'jsdoc/require-file-overview': 'off',
+      },
+    },
+    {
+      files: [
+        '*.html',
+        '*_test.js',
+        'a11y-test-utils.js',
       ],
       // Additional global variables allowed in tests
-      "globals": {
+      globals: {
         // Global variables from 3rd party test libraries/frameworks.
         // You can extend this list if you want to use other global
         // variables from these libraries and import is not possible
-        "MockInteractions": "readonly",
-        "_": "readonly",
-        "axs": "readonly",
-        "a11ySuite": "readonly",
-        "assert": "readonly",
-        "expect": "readonly",
-        "fixture": "readonly",
-        "flush": "readonly",
-        "flushAsynchronousOperations": "readonly",
-        "setup": "readonly",
-        "sinon": "readonly",
-        "stub": "readonly",
-        "suite": "readonly",
-        "suiteSetup": "readonly",
-        "suiteTeardown": "readonly",
-        "teardown": "readonly",
-        "test": "readonly",
-        "fixtureFromElement": "readonly",
-        "fixtureFromTemplate": "readonly",
-      }
+        MockInteractions: 'readonly',
+        _: 'readonly',
+        axs: 'readonly',
+        a11ySuite: 'readonly',
+        assert: 'readonly',
+        expect: 'readonly',
+        fixture: 'readonly',
+        flush: 'readonly',
+        setup: 'readonly',
+        sinon: 'readonly',
+        stub: 'readonly',
+        suite: 'readonly',
+        suiteSetup: 'readonly',
+        suiteTeardown: 'readonly',
+        teardown: 'readonly',
+        test: 'readonly',
+        fixtureFromElement: 'readonly',
+        fixtureFromTemplate: 'readonly',
+      },
     },
     {
-      "files": "import-href.js",
-      "globals": {
-        "HTMLImports": "readonly",
-      }
+      files: 'import-href.js',
+      globals: {
+        HTMLImports: 'readonly',
+      },
     },
     {
-      "files": ["samples/**/*.js"],
-      "globals": {
+      files: ['samples/**/*.js'],
+      globals: {
         // Settings for samples. You can add globals here if you want to use it
-        "Gerrit": "readonly",
-        "Polymer": "readonly",
-      }
+        Gerrit: 'readonly',
+        Polymer: 'readonly',
+      },
     },
     {
-      "files": ["test/functional/**/*.js"],
+      files: ['test/functional/**/*.js'],
       // Settings for functional tests. These scripts are node scripts.
       // Turn off "no-undef" to allow any global variable
-      "env": {
-        "browser": false,
-        "node": true,
-        "es6": false
+      env: {
+        browser: false,
+        node: true,
+        es6: false,
       },
-      "rules": {
-        "no-undef": "off",
-      }
+      rules: {
+        'no-undef': 'off',
+      },
     },
     {
-      "files": ["*_html.js", "gr-icons.js", "*-theme.js", "*-styles.js"],
-      "rules": {
-        "max-len": "off"
-      }
+      files: ['*_html.js', 'gr-icons.js', '*-theme.js', '*-styles.js'],
+      rules: {
+        'max-len': 'off',
+      },
     },
     {
-      "files": ["*_html.js"],
-      "rules": {
-        "prettier/prettier": ["error", {
-          "bracketSpacing": false,
-          "singleQuote": true,
-        }]
-      }
-    }
+      files: ['*_html.js'],
+      rules: {
+        'prettier/prettier': ['error', {
+          bracketSpacing: false,
+          singleQuote: true,
+        }],
+      },
+    },
   ],
-  "plugins": [
-    "html",
-    "jsdoc",
-    "import",
-    "prettier"
+  plugins: [
+    'html',
+    'jsdoc',
+    'import',
+    'prettier',
   ],
-  "settings": {
-    "html/report-bad-indent": "error",
-    "import/resolver": {
-      "node": {},
+  settings: {
+    'html/report-bad-indent': 'error',
+    'import/resolver': {
+      node: {},
       [path.resolve(__dirname, './.eslint-ts-resolver.js')]: {},
     },
   },
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index c29663c..47521f6 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -6,6 +6,7 @@
 # This list must be in sync with the "include" list in the follwoing files:
 # tsconfig.json, tsconfig_bazel.json, tsconfig_bazel_test.json
 src_dirs = [
+    "api",
     "constants",
     "elements",
     "embed",
@@ -129,6 +130,7 @@
     ],
     ignore = ".eslintignore",
     plugins = [
+        "@npm//@typescript-eslint/eslint-plugin",
         "@npm//eslint-config-google",
         "@npm//eslint-plugin-html",
         "@npm//eslint-plugin-import",
diff --git a/polygerrit-ui/app/api/README.md b/polygerrit-ui/app/api/README.md
new file mode 100644
index 0000000..550063f
--- /dev/null
+++ b/polygerrit-ui/app/api/README.md
@@ -0,0 +1,23 @@
+# API
+
+In this folder, we declare the API of various parts of the Gerrit webclient.
+There are two primary use cases for this:
+
+* apps that embed our diff viewer, gr-diff
+* Gerrit plugins that need to access some part of Gerrit to extend it
+
+Both may be built as a separate bundle, but would like to type check against
+the same types the Gerrit/gr-diff bundle uses. For this reason, this folder
+should contain only types, with the exception of enums, where having the
+value side is deemed an acceptable duplication.
+
+All types in here should use the `declare` keyword to prevent bundlers from
+renaming fields, which would break communication across separately built
+bundles. Again enums are the exception, because their keys are not referenced
+across bundles, and values will not be renamed by bundlers as they are strings.
+
+This API is used by other apps embedding gr-diff and any breaking changes
+should be discussed with the Gerrit core team and properly versioned.
+
+Gerrit types should either directly use or extend these types, so that
+breaking changes to the implementation require changes to these files.
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/api/admin.ts
similarity index 70%
rename from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
rename to polygerrit-ui/app/api/admin.ts
index ac59f4f..a7b549d 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/api/admin.ts
@@ -14,8 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+/** Interface for menu link */
+export interface MenuLink {
+  text: string;
+  url: string;
+  capability: string | null;
+}
+
+export interface AdminPluginApi {
+  addMenuLink(text: string, url: string, capability?: string): void;
+
+  getMenuLinks(): MenuLink[];
+}
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
new file mode 100644
index 0000000..bd4f399
--- /dev/null
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {CoverageRange, Side} from './diff';
+
+/**
+ * This is the callback object that Gerrit calls once for each diff. Gerrit
+ * is then responsible for styling the diff according the returned array of
+ * CoverageRanges.
+ */
+export type CoverageProvider = (
+  changeNum: number,
+  path: string,
+  basePatchNum?: number,
+  patchNum?: number,
+  /**
+   * This is a ChangeInfo object as defined here:
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
+   * At the moment we neither want to repeat it nor add a dependency on it here.
+   * TODO: Create a dedicated smaller object for exposing a change in the plugin
+   * API. Or allow the plugin API to depend on the entire rest API.
+   */
+  change?: unknown
+) => Promise<Array<CoverageRange>>;
+
+export 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.
+   *
+   * Be sure to call this only once and only from one plugin. Multiple coverage
+   * providers are not supported. A second call will just overwrite the
+   * provider of the first call.
+   */
+  setCoverageProvider(coverageProvider: CoverageProvider): AnnotationPluginApi;
+
+  /**
+   * For plugins notifying Gerrit about new annotations being ready to be
+   * applied for a certain range. Gerrit will then re-render the relevant lines
+   * of the diff and call back to the layer annotation function that was
+   * registered in addLayer().
+   *
+   * @param path The file path whose listeners should be notified.
+   * @param start The line where the update starts.
+   * @param end The line where the update ends.
+   * @param side The side of the update ('left' or 'right').
+   */
+  notify(path: string, start: number, end: number, side: Side): void;
+}
diff --git a/polygerrit-ui/app/api/attribute-helper.ts b/polygerrit-ui/app/api/attribute-helper.ts
new file mode 100644
index 0000000..cd52259
--- /dev/null
+++ b/polygerrit-ui/app/api/attribute-helper.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface AttributeHelperPluginApi {
+  /**
+   * Binds callback to property updates.
+   *
+   * @param name Property name.
+   * @return Unbind function.
+   */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  bind(name: string, callback: (value: any) => void): () => any;
+
+  /**
+   * Get value of the property from wrapped object. Waits for the property
+   * to be initialized if it isn't defined.
+   */
+  get(name: string): Promise<unknown>;
+
+  /**
+   * Sets value and dispatches event to force notify.
+   */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  set(name: string, value: any): void;
+}
diff --git a/polygerrit-ui/app/api/change-actions.ts b/polygerrit-ui/app/api/change-actions.ts
new file mode 100644
index 0000000..792f31e
--- /dev/null
+++ b/polygerrit-ui/app/api/change-actions.ts
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {HttpMethod} from './rest';
+
+export interface ActionInfo {
+  method?: HttpMethod;
+  label?: string;
+  title?: string;
+  enabled?: boolean;
+}
+
+export enum ActionType {
+  CHANGE = 'change',
+  REVISION = 'revision',
+}
+
+export enum ActionPriority {
+  CHANGE = 2,
+  DEFAULT = 0,
+  PRIMARY = 3,
+  REVIEW = -3,
+  REVISION = 1,
+}
+
+export enum ChangeActions {
+  ABANDON = 'abandon',
+  DELETE = '/',
+  DELETE_EDIT = 'deleteEdit',
+  EDIT = 'edit',
+  FOLLOW_UP = 'followup',
+  IGNORE = 'ignore',
+  MOVE = 'move',
+  PRIVATE = 'private',
+  PRIVATE_DELETE = 'private.delete',
+  PUBLISH_EDIT = 'publishEdit',
+  REBASE = 'rebase',
+  REBASE_EDIT = 'rebaseEdit',
+  READY = 'ready',
+  RESTORE = 'restore',
+  REVERT = 'revert',
+  REVERT_SUBMISSION = 'revert_submission',
+  REVIEWED = 'reviewed',
+  STOP_EDIT = 'stopEdit',
+  SUBMIT = 'submit',
+  UNIGNORE = 'unignore',
+  UNREVIEWED = 'unreviewed',
+  WIP = 'wip',
+}
+
+export enum RevisionActions {
+  CHERRYPICK = 'cherrypick',
+  REBASE = 'rebase',
+  SUBMIT = 'submit',
+  DOWNLOAD = 'download',
+}
+
+export type PrimaryActionKey = ChangeActions | RevisionActions;
+
+export interface ChangeActionsPluginApi {
+  addPrimaryActionKey(key: PrimaryActionKey): void;
+
+  removePrimaryActionKey(key: string): void;
+
+  hideQuickApproveAction(): void;
+
+  setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
+
+  setActionPriority(
+    type: ActionType,
+    key: string,
+    priority: ActionPriority
+  ): void;
+
+  setActionHidden(type: ActionType, key: string, hidden: boolean): void;
+
+  add(type: ActionType, label: string): string;
+
+  remove(key: string): void;
+
+  addTapListener(
+    key: string,
+    handler: EventListenerOrEventListenerObject
+  ): void;
+
+  removeTapListener(
+    key: string,
+    handler: EventListenerOrEventListenerObject
+  ): void;
+
+  setLabel(key: string, text: string): void;
+
+  setTitle(key: string, text: string): void;
+
+  setEnabled(key: string, enabled: boolean): void;
+
+  setIcon(key: string, icon: string): void;
+
+  getActionDetails(action: string): ActionInfo | undefined;
+}
diff --git a/polygerrit-ui/app/api/change-reply.ts b/polygerrit-ui/app/api/change-reply.ts
new file mode 100644
index 0000000..6016004
--- /dev/null
+++ b/polygerrit-ui/app/api/change-reply.ts
@@ -0,0 +1,37 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export interface LabelsChangedDetail {
+  name: string;
+  value: string;
+}
+export interface ValueChangedDetail {
+  value: string;
+}
+export type ReplyChangedCallback = (text: string) => void;
+export type LabelsChangedCallback = (detail: LabelsChangedDetail) => void;
+
+export interface ChangeReplyPluginApi {
+  getLabelValue(label: string): string;
+
+  setLabelValue(label: string, value: string): void;
+
+  addReplyTextChangedCallback(handler: ReplyChangedCallback): void;
+
+  addLabelValuesChangedCallback(handler: LabelsChangedCallback): void;
+
+  showMessage(message: string): void;
+}
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
new file mode 100644
index 0000000..ee4ea9e
--- /dev/null
+++ b/polygerrit-ui/app/api/checks.ts
@@ -0,0 +1,430 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Settings
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface ChecksPluginApi {
+  /**
+   * Must only be called once. You cannot register twice. You cannot unregister.
+   */
+  register(provider: ChecksProvider, config?: ChecksApiConfig): void;
+
+  /**
+   * Forces Gerrit to call fetch() on the registered provider. Can be called
+   * when the provider has gotten an update and does not want wait for the next
+   * polling interval to pass.
+   */
+  announceUpdate(): void;
+}
+
+export interface ChecksApiConfig {
+  /**
+   * How often should the provider be called for new CheckData while the user
+   * navigates change related pages and the browser tab remains visible?
+   * Set to 0 to disable polling. Default is 60 seconds.
+   */
+  fetchPollingIntervalSeconds: number;
+}
+
+export interface ChangeData {
+  changeNumber: number;
+  patchsetNumber: number;
+  patchsetSha: string;
+  repo: string;
+  commmitMessage?: string;
+  /* TODO(brohlfs): Add dep to Rest API types and replace type by ChangeInfo. */
+  changeInfo: unknown;
+}
+
+export interface ChecksProvider {
+  /**
+   * Gerrit calls this method when ...
+   * - ... the change or diff page is loaded.
+   * - ... the user switches back to a Gerrit tab with a change or diff page.
+   * - ... while the tab is visible in a regular polling interval, see
+   *       ChecksApiConfig.
+   */
+  fetch(change: ChangeData): Promise<FetchResponse>;
+}
+
+export interface FetchResponse {
+  responseCode: ResponseCode;
+
+  /** Only relevant when the responseCode is ERROR. */
+  errorMessage?: string;
+
+  /**
+   * Only relevant when the responseCode is NOT_LOGGED_IN.
+   * Gerrit displays a "Login" button and calls this callback when the user
+   * clicks on the button.
+   */
+  loginCallback?: () => void;
+
+  /**
+   * Top-level actions that are not associated with a specific run or result.
+   * Will be shown as buttons in the header of the Checks tab.
+   */
+  actions?: Action[];
+
+  /**
+   * Top-level links that are not associated with a specific run or result.
+   * Will be shown as icons in the header of the Checks tab.
+   */
+  links?: Link[];
+
+  runs?: CheckRun[];
+}
+
+export enum ResponseCode {
+  OK = 'OK',
+  ERROR = 'ERROR',
+  NOT_LOGGED_IN = 'NOT_LOGGED_IN',
+}
+
+/**
+ * A CheckRun models an entity that has start/end timestamps and can be in
+ * either of the states RUNNABLE, RUNNING, COMPLETED. By itself it cannot model
+ * an error, neither can it be failed or successful by itself. A run can be
+ * associated with 0 to n results (see below). So until runs are completed the
+ * runs are more interesting for the user: What is going on at the moment? When
+ * runs are completed the users' interest shifts to results: What do I have to
+ * fix? The only actions that can be associated with runs are RUN and CANCEL.
+ */
+export interface CheckRun {
+  /**
+   * Gerrit requests check runs and results from the plugin by change number and
+   * patchset number. So these two properties can as well be left empty when
+   * returning results to the Gerrit UI and are thus optional.
+   */
+  change?: number;
+  /**
+   * Typically only runs for the latest patchset are requested and presented.
+   * Older runs and their results are only available on request, e.g. by
+   * switching to another patchset in a dropdown
+   *
+   * TBD: Check data providers may decide that runs and results are applicable
+   * to a newer patchset, even if they were produced for an older, e.g. because
+   * only the commit message was changed. Maybe that warrants the addition of
+   * another optional field, e.g. `original_patchset`.
+   */
+  patchset?: number;
+  /**
+   * The UI will focus on just the latest attempt per run. Former attempts are
+   * accessible, but initially collapsed/hidden. Lower number means older
+   * attempt. Every run has its own attempt numbering, so attempt 3 of run A is
+   * not directly related to attempt 3 of run B.
+   *
+   * RUNNABLE runs must use `undefined` as attempt.
+   * COMPLETED and RUNNING runs must use an attempt number >=0.
+   *
+   * TBD: Optionally providing aggregate information about former attempts will
+   * probably be a useful feature, but we are deferring the exact data modeling
+   * of that to later.
+   */
+  attempt?: number;
+
+  /**
+   * An optional opaque identifier not used by Gerrit directly, but might be
+   * used by plugin extensions and callbacks.
+   */
+  externalId?: string;
+
+  // The following 3 properties are independent of this run *instance*. They
+  // just describe what the check is about and will be identical for other
+  // attempts or patchsets or changes.
+
+  /**
+   * The unique name of the check. There can’t be two runs with the same
+   * change/patchset/attempt/checkName combination.
+   * Multiple attempts of the same run must have the same checkName.
+   * It should be expected that this string is cut off at ~30 chars in the UI.
+   * The full name will then be shown in a tooltip.
+   */
+  checkName: string;
+  /**
+   * Optional description of the check. Only shown as a tooltip or in a
+   * hovercard.
+   */
+  checkDescription?: string;
+  /**
+   * Optional http link to an external page with more detailed information about
+   * this run. Must begin with 'http'.
+   */
+  checkLink?: string;
+
+  /**
+   * RUNNABLE:  Not run (yet). Mostly useful for runs that the user can trigger
+   *            (see actions). Cannot contain results.
+   * RUNNING:   Subsumes "scheduled".
+   * COMPLETED: The attempt of the run has finished. Does not indicate at all
+   *            whether the run was successful or not. Outcomes can and should
+   *            be modeled using the CheckResult entity.
+   */
+  status: RunStatus;
+  /**
+   * Optional short description of the run status. This is a plain string
+   * without styling or formatting options. It will only be shown as a tooltip
+   * or in a hovercard.
+   *
+   * Examples:
+   * "40 tests running, 30 completed: 0 failing so far",
+   * "Scheduled 5 minutes ago, not running yet".
+   */
+  statusDescription?: string;
+  /**
+   * Optional http link to an external page with more detailed information about
+   * the run status. Must begin with 'http'.
+   */
+  statusLink?: string;
+
+  /**
+   * Optional reference to a Gerrit label (e.g. "Verified") that this result
+   * influences. Allows the user to understand and navigate the relationship
+   * between check runs/results and submit requirements,
+   * see also https://gerrit-review.googlesource.com/c/homepage/+/279176.
+   */
+  labelName?: string;
+
+  /**
+   * Optional callbacks to the plugin. Must be implemented individually by
+   * each plugin. The most important actions (which get special UI treatment)
+   * are:
+   * "Run" for RUNNABLE and COMPLETED runs.
+   * "Cancel" for RUNNING runs.
+   */
+  actions?: Action[];
+
+  scheduledTimestamp?: Date;
+  startedTimestamp?: Date;
+  finishedTimestamp?: Date;
+
+  /**
+   * List of results produced by this run.
+   * RUNNABLE runs must not have results.
+   * RUNNING runs can contain (intermediate) results.
+   * Nesting the results in runs enforces that:
+   * - A run can have 0-n results.
+   * - A result is associated with exactly one run.
+   */
+  results?: CheckResult[];
+}
+
+export interface Action {
+  name: string;
+  tooltip?: string;
+  /**
+   * 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;
+  callback: ActionCallback;
+}
+
+export type ActionCallback = (
+  change: number,
+  patchset: number,
+  /**
+   * Identical to 'attempt' property of CheckRun. Not set for top-level
+   * actions.
+   */
+  attempt: number | undefined,
+  /**
+   * Identical to 'externalId' property of CheckRun. Not set for top-level
+   * actions.
+   */
+  externalId: string | undefined,
+  /**
+   * Identical to 'checkName' property of CheckRun. Not set for top-level
+   * actions.
+   */
+  checkName: string | undefined,
+  /** Identical to 'name' property of Action entity. */
+  actionName: string
+  /**
+   * If the callback does not return a promise, then the user will get no
+   * feedback from the Gerrit UI. This is adequate when the plugin opens a
+   * dialog for example. If a Promise<ActionResult> is returned, then Gerrit
+   * will show toasts for user feedback, see ActionResult below.
+   */
+) => Promise<ActionResult> | undefined;
+
+/**
+ * Until the Promise<ActionResult> resolves (max. 5 seconds) Gerrit will show a
+ * toast with the message `Triggering action '${action.name}' ...`.
+ *
+ * When the promise resolves (within 5 seconds) then Gerrit will replace the
+ * toast with a new one with the message `${message}` and show it for 5 seconds.
+ * If `message` is empty or undefined, then the `Triggering ...` toast will just
+ * be hidden and no further toast will be shown.
+ */
+export interface ActionResult {
+  /** An empty errorMessage means success. */
+  message?: string;
+  /**
+   * If true, then ChecksProvider.fetch() is called. Has the same affect as if
+   * the plugin would call announceUpdate(). So just for convenience.
+   */
+  shouldReload?: boolean;
+  /** DEPRECATED: Use `message` instead. */
+  errorMessage?: string;
+}
+
+export enum RunStatus {
+  RUNNABLE = 'RUNNABLE',
+  RUNNING = 'RUNNING',
+  COMPLETED = 'COMPLETED',
+}
+
+export interface CheckResult {
+  /**
+   * An optional opaque identifier not used by Gerrit directly, but might be
+   * used by plugin extensions and callbacks.
+   */
+  externalId?: string;
+
+  /**
+   * INFO:    The user will typically not bother to look into this category,
+   *          only for looking up something that they are searching for. Can be
+   *          used for reporting secondary metrics and analysis, or a wider
+   *          range of artifacts produced by the checks system.
+   * WARNING: A warning is something that should be read before submitting the
+   *          change. The user should not ignore it, but it is also not blocking
+   *          submit. It has a similar level of importance as an unresolved
+   *          comment.
+   * ERROR:   An error indicates that the change must not or cannot be submitted
+   *          without fixing the problem. Errors will be visualized very
+   *          prominently to the user.
+   *
+   * The ‘tags’ field below can be used for further categorization, e.g. for
+   * distinguishing FAILED vs TIMED_OUT.
+   */
+  category: Category;
+
+  /**
+   * Short description of the check result.
+   *
+   * It should be expected that this string might be cut off at ~80 chars in the
+   * UI. The full description will then be shown in a tooltip.
+   * This is a plain string without styling or formatting options.
+   *
+   * Examples:
+   * MessageConverterTest failed with: 'kermit' expected, but got 'ernie'.
+   * Binary size of javascript bundle has increased by 27%.
+   */
+  summary: string;
+
+  /**
+   * Exhaustive optional message describing the check result.
+   * Will be initially collapsed. Might potentially be very long, e.g. a log of
+   * MB size. The UI is not limiting this. Data providing plugins are
+   * responsible for not killing the browser. :-)
+   *
+   * For now this is just a plain unformatted string. The only formatting
+   * applied is the one that Gerrit also applies to human comments. TBD: Both
+   * human comments and check result messages should get richer formatting
+   * options.
+   */
+  message?: string;
+
+  /**
+   * Tags allow a plugins to further categorize a result, e.g. making a list
+   * of results filterable by the end-user.
+   * The name is free-form, but there is a predefined set of TagColors to
+   * choose from with a recommendation of color for common tags, see below.
+   *
+   * Examples:
+   * PASS, FAIL, SCHEDULED, OBSOLETE, SKIPPED, TIMED_OUT, INFRA_ERROR, FLAKY
+   * WIN, MAC, LINUX
+   * BUILD, TEST, LINT
+   * INTEGRATION, E2E, SCREENSHOT
+   */
+  tags?: Tag[];
+
+  /**
+   * Links provide an opportunity for the end-user to easily access details and
+   * artifacts. Links are displayed by an icon+tooltip only. They don’t have a
+   * name, making them clearly distinguishable from tags and actions.
+   *
+   * There is a fixed set of LinkIcons to choose from, see below.
+   *
+   * Examples:
+   * Link to test log.
+   * Link to result artifacts such as images and screenshots.
+   * Link to downloadable artifacts such as ZIP or APK files.
+   */
+  links?: Link[];
+
+  /**
+   * Callbacks to the plugin. Must be implemented individually by each
+   * plugin. Actions are rendered as buttons. If there are more than two actions
+   * per result, then further actions are put into an overflow menu. Sort order
+   * is defined by the data provider.
+   *
+   * Examples:
+   * Acknowledge/Dismiss, Delete, Report a bug, Report as not useful,
+   * Make blocking, Downgrade severity.
+   */
+  actions?: Action[];
+}
+
+export enum Category {
+  INFO = 'INFO',
+  WARNING = 'WARNING',
+  ERROR = 'ERROR',
+}
+
+export interface Tag {
+  name: string;
+  tooltip?: string;
+  color?: TagColor;
+}
+
+// TBD: Clarify standard colors for common tags.
+export enum TagColor {
+  GRAY = 'gray',
+  YELLOW = 'yellow',
+  PINK = 'pink',
+  PURPLE = 'purple',
+  CYAN = 'cyan',
+  BROWN = 'brown',
+}
+
+export 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.
+   */
+  primary: boolean;
+  icon: LinkIcon;
+}
+
+export enum LinkIcon {
+  EXTERNAL = 'external',
+  IMAGE = 'image',
+  HISTORY = 'history',
+  DOWNLOAD = 'download',
+  DOWNLOAD_MOBILE = 'download_mobile',
+  HELP_PAGE = 'help_page',
+  REPORT_BUG = 'report_bug',
+}
diff --git a/polygerrit-ui/app/api/core.ts b/polygerrit-ui/app/api/core.ts
new file mode 100644
index 0000000..5820139
--- /dev/null
+++ b/polygerrit-ui/app/api/core.ts
@@ -0,0 +1,47 @@
+/**
+ * @fileoverview Core API types for Gerrit.
+ *
+ * Core types are types used in many places in Gerrit, such as the Side enum.
+ *
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The CommentRange entity describes the range of an inline comment.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
+ *
+ * The range includes all characters from the start position, specified by
+ * start_line and start_character, to the end position, specified by end_line
+ * and end_character. The start position is inclusive and the end position is
+ * exclusive.
+ *
+ * So, a range over part of a line will have start_line equal to end_line;
+ * however a range with end_line set to 5 and end_character equal to 0 will not
+ * include any characters on line 5.
+ */
+export declare interface CommentRange {
+  /** The start line number of the range. (1-based) */
+  start_line: number;
+
+  /** The character position in the start line. (0-based) */
+  start_character: number;
+
+  /** The end line number of the range. (1-based) */
+  end_line: number;
+
+  /** The character position in the end line. (0-based) */
+  end_character: number;
+}
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
new file mode 100644
index 0000000..9cd8cb3
--- /dev/null
+++ b/polygerrit-ui/app/api/diff.ts
@@ -0,0 +1,290 @@
+/**
+ * @fileoverview The API of Gerrit's diff viewer, gr-diff.
+ *
+ * This includes some types which are also defined as part of Gerrit's JSON API
+ * which are used as inputs to gr-diff.
+ *
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {CommentRange} from './core';
+
+/**
+ * Diff type in preferences
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ */
+export enum DiffViewMode {
+  SIDE_BY_SIDE = 'SIDE_BY_SIDE',
+  UNIFIED = 'UNIFIED_DIFF',
+}
+
+/**
+ * The DiffInfo entity contains information about the diff of a file in a
+ * revision.
+ *
+ * If the weblinks-only parameter is specified, only the web_links field is set.
+ */
+export declare interface DiffInfo {
+  /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
+  meta_a: DiffFileMetaInfo;
+  /** Meta information about the file on side B as a DiffFileMetaInfo entity. */
+  meta_b: DiffFileMetaInfo;
+  /** The type of change (ADDED, MODIFIED, DELETED, RENAMED COPIED, REWRITE). */
+  change_type: ChangeType;
+  /** Intraline status (OK, ERROR, TIMEOUT). */
+  intraline_status: 'OK' | 'Error' | 'Timeout';
+  /** The content differences in the file as a list of DiffContent entities. */
+  content: DiffContent[];
+  /** Whether the file is binary. */
+  binary?: boolean;
+}
+
+/**
+ * The DiffFileMetaInfo entity contains meta information about a file diff.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-file-meta-info
+ */
+export declare interface DiffFileMetaInfo {
+  /** The name of the file. */
+  name: string;
+  /** The content type of the file. */
+  content_type: string;
+  /** The total number of lines in the file. */
+  lines: number;
+  // TODO: Not documented.
+  language?: string;
+}
+
+export declare type ChangeType =
+  | 'ADDED'
+  | 'MODIFIED'
+  | 'DELETED'
+  | 'RENAMED'
+  | 'COPIED'
+  | 'REWRITE';
+
+/**
+ * The DiffContent entity contains information about the content differences in
+ * a file.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-content
+ */
+export declare interface DiffContent {
+  /** Content only in the file on side A (deleted in B). */
+  a?: string[];
+  /** Content only in the file on side B (added in B). */
+  b?: string[];
+  /** Content in the file on both sides (unchanged). */
+  ab?: string[];
+  /**
+   * Text sections deleted from side A as a DiffIntralineInfo entity.
+   *
+   * Only present during a replace, i.e. both a and b are present.
+   */
+  edit_a?: DiffIntralineInfo[];
+  /**
+   * Text sections inserted in side B as a DiffIntralineInfo entity.
+   *
+   * Only present during a replace, i.e. both a and b are present.
+   */
+  edit_b?: DiffIntralineInfo[];
+  /** Indicates whether this entry was introduced by a rebase. */
+  due_to_rebase?: boolean;
+
+  /**
+   * Provides info about a move operation the chunk.
+   * It's presence indicates the current chunk exists due to a move.
+   */
+  move_details?: MoveDetails;
+  /**
+   * Count of lines skipped on both sides when the file is too large to include
+   * all common lines.
+   */
+  skip?: number;
+  /**
+   * Set to true if the region is common according to the requested
+   * ignore-whitespace parameter, but a and b contain differing amounts of
+   * whitespace. When present and true a and b are used instead of ab.
+   */
+  common?: boolean;
+}
+
+/**
+ * Details about move operation related to a specific chunk.
+ */
+export declare interface MoveDetails {
+  /** Indicates whether the content of the chunk changes while moving code */
+  changed: boolean;
+  /**
+   * Indicates the range (line numbers) on the other side of the comparison
+   * where the code related to the current chunk came from/went to.
+   */
+  range: {
+    start: number;
+    end: number;
+  };
+}
+
+/**
+ * The DiffIntralineInfo entity contains information about intraline edits in a
+ * file.
+ *
+ * The information consists of a list of <skip length, mark length> pairs, where
+ * the skip length is the number of characters between the end of the previous
+ * edit and the start of this edit, and the mark length is the number of edited
+ * characters following the skip. The start of the edits is from the beginning
+ * of the related diff content lines.
+ *
+ * Note that the implied newline character at the end of each line is included
+ * in the length calculation, and thus it is possible for the edits to span
+ * newlines.
+ */
+export declare type SkipLength = number;
+export declare type MarkLength = number;
+export declare type DiffIntralineInfo = [SkipLength, MarkLength];
+
+/**
+ * The DiffPreferencesInfo entity contains information about the diff
+ * preferences of a user.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-info
+ */
+export declare interface DiffPreferencesInfo {
+  context: number;
+  ignore_whitespace: IgnoreWhitespaceType;
+  line_length: number;
+  show_line_endings?: boolean;
+  show_tabs?: boolean;
+  show_whitespace_errors?: boolean;
+  syntax_highlighting?: boolean;
+  tab_size: number;
+  font_size: number;
+  // TODO: Missing documentation
+  show_file_comment_button?: boolean;
+}
+
+export declare interface RenderPreferences {
+  hide_left_side?: boolean;
+  disable_context_control_buttons?: boolean;
+  show_file_comment_button?: boolean;
+  hide_line_length_indicator?: boolean;
+}
+
+/**
+ * Whether whitespace changes should be ignored and if yes, which whitespace
+ * changes should be ignored
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-input
+ */
+export declare type IgnoreWhitespaceType =
+  | 'IGNORE_NONE'
+  | 'IGNORE_TRAILING'
+  | 'IGNORE_LEADING_AND_TRAILING'
+  | 'IGNORE_ALL';
+
+export enum Side {
+  LEFT = 'left',
+  RIGHT = 'right',
+}
+
+export enum CoverageType {
+  /**
+   * start_character and end_character of the range will be ignored for this
+   * type.
+   */
+  COVERED = 'COVERED',
+  /**
+   * start_character and end_character of the range will be ignored for this
+   * type.
+   */
+  NOT_COVERED = 'NOT_COVERED',
+  PARTIALLY_COVERED = 'PARTIALLY_COVERED',
+  /**
+   * You don't have to use this. If there is no coverage information for a
+   * range, then it implicitly means NOT_INSTRUMENTED. start_character and
+   * end_character of the range will be ignored for this type.
+   */
+  NOT_INSTRUMENTED = 'NOT_INSTRUMENTED',
+}
+
+export declare interface LineRange {
+  start_line: number;
+  end_line: number;
+}
+
+export declare interface CoverageRange {
+  type: CoverageType;
+  side: Side;
+  code_range: LineRange;
+}
+
+/** LOST LineNumber is for ported comments without a range, they have their own
+ *  line number and are added on top of the FILE row in gr-diff
+ */
+export declare type LineNumber = number | 'FILE' | 'LOST';
+
+/** The detail of the 'create-comment' event dispatched by gr-diff. */
+export declare interface CreateCommentEventDetail {
+  side: Side;
+  lineNum: LineNumber;
+  range: CommentRange | undefined;
+}
+
+export declare interface ContentLoadNeededEventDetail {
+  lineRange: {
+    left: LineRange;
+    right: LineRange;
+  };
+}
+
+export declare interface MovedLinkClickedEventDetail {
+  side: Side;
+  lineNum: LineNumber;
+}
+
+export enum GrDiffLineType {
+  ADD = 'add',
+  BOTH = 'both',
+  BLANK = 'blank',
+  REMOVE = 'remove',
+}
+
+/** Describes a line to be rendered in a diff. */
+export declare interface GrDiffLine {
+  readonly type: GrDiffLineType;
+  /** The line number on the left side of the diff - 0 means none.  */
+  beforeNumber: LineNumber;
+  /** The line number on the right side of the diff - 0 means none.  */
+  afterNumber: LineNumber;
+}
+
+/**
+ * Interface to implemented to define a new layer in the diff.
+ *
+ * Layers can affect how the text of the diff or its line numbers
+ * are rendered.
+ */
+export declare interface DiffLayer {
+  /**
+   * Called during rendering and allows annotating the diff text or line number
+   * by mutating those elements.
+   *
+   * @param textElement The rendered text of one side of the diff.
+   * @param lineNumberElement The rendered line number of one side of the diff.
+   * @param line Describes the line that should be annotated.
+   */
+  annotate(
+    textElement: HTMLElement,
+    lineNumberElement: HTMLElement,
+    line: GrDiffLine
+  ): void;
+}
diff --git a/polygerrit-ui/app/styles/themes/dark-theme_test.js b/polygerrit-ui/app/api/event-helper.ts
similarity index 61%
copy from polygerrit-ui/app/styles/themes/dark-theme_test.js
copy to polygerrit-ui/app/api/event-helper.ts
index 4f6466f..16c327d 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme_test.js
+++ b/polygerrit-ui/app/api/event-helper.ts
@@ -14,15 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+export type UnsubscribeCallback = () => void;
 
-import '../../test/common-test-setup-karma.js';
-import {applyTheme, removeTheme} from './dark-theme.js';
+export interface EventHelperPluginApi {
+  /**
+   * Alias for @see onClick
+   */
+  onTap(callback: (event: Event) => boolean): UnsubscribeCallback;
 
-suite('dark-theme_test.js', () => {
-  test('apply and remove theme', () => {
-    applyTheme();
-    assert.equal(document.head.querySelectorAll('#dark-theme').length, 1);
-    removeTheme();
-    assert.equal(document.head.querySelectorAll('#dark-theme').length, 0);
-  });
-});
+  /**
+   * Add a callback to element click or touch.
+   * The callback may return false to prevent event bubbling.
+   */
+  onClick(callback: (event: Event) => boolean): UnsubscribeCallback;
+}
diff --git a/polygerrit-ui/app/api/hook.ts b/polygerrit-ui/app/api/hook.ts
new file mode 100644
index 0000000..179b967
--- /dev/null
+++ b/polygerrit-ui/app/api/hook.ts
@@ -0,0 +1,52 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+interface GerritElementExtensions {
+  content?: HTMLElement & {hidden?: boolean};
+  change?: unknown;
+  revision?: unknown;
+  token?: string;
+  repoName?: string;
+  /**
+   * This is a ConfigInfo object as defined here:
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
+   * We neither want to repeat it nor add a dependency on it here.
+   */
+  config?: unknown;
+}
+
+export type HookCallback = (el: HTMLElement & GerritElementExtensions) => void;
+
+export interface RegisterOptions {
+  slot?: string;
+  replace: unknown;
+}
+
+export interface HookApi {
+  onAttached(callback: HookCallback): HookApi;
+
+  onDetached(callback: HookCallback): HookApi;
+
+  getAllAttached(): HTMLElement[];
+
+  getLastAttached(): Promise<HTMLElement>;
+
+  getModuleName(): string;
+
+  handleInstanceDetached(instance: HTMLElement): void;
+
+  handleInstanceAttached(instance: HTMLElement): void;
+}
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
new file mode 100644
index 0000000..0aadb38
--- /dev/null
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {AdminPluginApi} from './admin';
+import {AnnotationPluginApi} from './annotation';
+import {AttributeHelperPluginApi} from './attribute-helper';
+import {ChangeReplyPluginApi} from './change-reply';
+import {ChecksPluginApi} from './checks';
+import {EventHelperPluginApi} from './event-helper';
+import {PopupPluginApi} from './popup';
+import {ReportingPluginApi} from './reporting';
+import {ChangeActionsPluginApi} from './change-actions';
+import {RestPluginApi} from './rest';
+import {HookApi, RegisterOptions} from './hook';
+
+export enum TargetElement {
+  CHANGE_ACTIONS = 'changeactions',
+  REPLY_DIALOG = 'replydialog',
+}
+
+// Note: for new events, naming convention should be: `a-b`
+export enum EventType {
+  HISTORY = 'history',
+  LABEL_CHANGE = 'labelchange',
+  SHOW_CHANGE = 'showchange',
+  SUBMIT_CHANGE = 'submitchange',
+  SHOW_REVISION_ACTIONS = 'show-revision-actions',
+  COMMIT_MSG_EDIT = 'commitmsgedit',
+  COMMENT = 'comment',
+  REVERT = 'revert',
+  REVERT_SUBMISSION = 'revert_submission',
+  POST_REVERT = 'postrevert',
+  ANNOTATE_DIFF = 'annotatediff',
+  ADMIN_MENU_LINKS = 'admin-menu-links',
+  HIGHLIGHTJS_LOADED = 'highlightjs-loaded',
+}
+
+export interface PluginApi {
+  _url?: URL;
+  admin(): AdminPluginApi;
+  annotationApi(): AnnotationPluginApi;
+  attributeHelper(element: Element): AttributeHelperPluginApi;
+  changeActions(): ChangeActionsPluginApi;
+  changeReply(): ChangeReplyPluginApi;
+  checks(): ChecksPluginApi;
+  eventHelper(element: Node): EventHelperPluginApi;
+  getPluginName(): string;
+  hook(endpointName: string, opt_options?: RegisterOptions): HookApi;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  on(eventName: EventType, target: any): void;
+  popup(): Promise<PopupPluginApi>;
+  popup(moduleName: string): Promise<PopupPluginApi>;
+  popup(moduleName?: string): Promise<PopupPluginApi | null>;
+  registerCustomComponent(
+    endpointName: string,
+    moduleName?: string,
+    options?: RegisterOptions
+  ): HookApi;
+  registerDynamicCustomComponent(
+    endpointName: string,
+    moduleName?: string,
+    options?: RegisterOptions
+  ): HookApi;
+  registerStyleModule(endpoint: string, moduleName: string): void;
+  reporting(): ReportingPluginApi;
+  restApi(): RestPluginApi;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  screen(screenName: string, moduleName?: string): any;
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/api/popup.ts
similarity index 65%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/api/popup.ts
index ac59f4f..60772cc 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/api/popup.ts
@@ -14,8 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+export interface PopupPluginApi {
+  /**
+   * Opens the popup, inserts it into DOM over current UI.
+   * Creates the popup if not previously created. Creates popup content element,
+   * if it was provided with constructor.
+   */
+  open(): Promise<PopupPluginApi>;
+
+  /**
+   * Hides the popup.
+   */
+  close(): void;
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/api/reporting.ts
similarity index 69%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/api/reporting.ts
index ac59f4f..65bdc3f 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/api/reporting.ts
@@ -14,8 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type EventDetails = any;
+
+export interface ReportingPluginApi {
+  reportInteraction(eventName: string, details?: EventDetails): void;
+
+  reportLifeCycle(eventName: string, details?: EventDetails): void;
+}
diff --git a/polygerrit-ui/app/api/rest.ts b/polygerrit-ui/app/api/rest.ts
new file mode 100644
index 0000000..fd9cada
--- /dev/null
+++ b/polygerrit-ui/app/api/rest.ts
@@ -0,0 +1,106 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export type RequestPayload = string | object;
+
+export enum HttpMethod {
+  HEAD = 'HEAD',
+  POST = 'POST',
+  GET = 'GET',
+  DELETE = 'DELETE',
+  PUT = 'PUT',
+}
+
+export type ErrorCallback = (response?: Response | null, err?: Error) => void;
+
+export interface RestPluginApi {
+  getLoggedIn(): Promise<boolean>;
+
+  getVersion(): Promise<string | undefined>;
+
+  /**
+   * Returns a ServerInfo object as defined here:
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#server-info
+   * We neither want to repeat it nor add a dependency on it here.
+   */
+  getConfig(): Promise<unknown>;
+
+  invalidateReposCache(): void;
+
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload?: RequestPayload,
+    errFn?: undefined,
+    contentType?: string
+  ): Promise<Response>;
+
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload: RequestPayload | undefined,
+    errFn: ErrorCallback,
+    contentType?: string
+  ): Promise<Response | void>;
+
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload: RequestPayload | undefined,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<Response | void>;
+
+  /**
+   * Fetch and return native browser REST API Response.
+   */
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<Response | void>;
+
+  /**
+   * Fetch and parse REST API response, if request succeeds.
+   */
+  send(
+    method: HttpMethod,
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<unknown>;
+
+  get(url: string): Promise<unknown>;
+
+  post(
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<unknown>;
+
+  put(
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<unknown>;
+
+  delete(url: string): Promise<Response>;
+}
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index b64a7d1..be502f7 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -18,13 +18,18 @@
 /**
  * @desc Tab names for primary tabs on change view page.
  */
+import {DiffViewMode} from '../api/diff';
+import {DiffPreferencesInfo} from '../types/diff';
+import {EditPreferencesInfo, PreferencesInfo} from '../types/common';
+
 export enum PrimaryTab {
   FILES = 'files',
   /**
-   * When renaming this, the links in UrlFormatter must be updated.
+   * When renaming 'comments' or 'findings', UrlFormatter.java must be updated.
    */
   COMMENT_THREADS = 'comments',
   FINDINGS = 'findings',
+  CHECKS = 'checks',
 }
 
 /**
@@ -48,6 +53,7 @@
   TAG_SET_WIP = 'autogenerated:gerrit:setWorkInProgress',
   TAG_SET_ASSIGNEE = 'autogenerated:gerrit:setAssignee',
   TAG_UNSET_ASSIGNEE = 'autogenerated:gerrit:deleteAssignee',
+  TAG_MERGED = 'autogenerated:gerrit:merged',
 }
 
 /**
@@ -156,10 +162,7 @@
   HIDDEN = 'HIDDEN',
 }
 
-export enum Side {
-  LEFT = 'left',
-  RIGHT = 'right',
-}
+export {Side} from '../api/diff';
 
 /**
  * The type in ConfigParameterInfo entity.
@@ -287,14 +290,7 @@
   HHMM_24 = 'HHMM_24',
 }
 
-/**
- * Diff type in preferences
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
- */
-export enum DiffViewMode {
-  SIDE_BY_SIDE = 'SIDE_BY_SIDE',
-  UNIFIED = 'UNIFIED_DIFF',
-}
+export {DiffViewMode};
 
 /**
  * The type of email strategy to use.
@@ -327,17 +323,6 @@
 }
 
 /**
- * Whether whitespace changes should be ignored and if yes, which whitespace changes should be ignored
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-input
- */
-export enum IgnoreWhitespaceType {
-  IGNORE_NONE = 'IGNORE_NONE',
-  IGNORE_TRAILING = 'IGNORE_TRAILING',
-  IGNORE_LEADING_AND_TRAILING = 'IGNORE_LEADING_AND_TRAILING',
-  IGNORE_ALL = 'IGNORE_ALL',
-}
-
-/**
  * how draft comments are handled
  */
 export enum DraftsAction {
@@ -400,3 +385,57 @@
   REF_UPDATED_AND_CHANGE_REINDEX = 'REF_UPDATED_AND_CHANGE_REINDEX',
   NEVER = 'NEVER',
 }
+
+// TODO(TS): Many properties are omitted here, but they are required.
+// Add default values for missing properties.
+export function createDefaultPreferences() {
+  return {
+    changes_per_page: 25,
+    default_diff_view: DiffViewMode.SIDE_BY_SIDE,
+    diff_view: DiffViewMode.SIDE_BY_SIDE,
+    size_bar_in_change_table: true,
+  } as PreferencesInfo;
+}
+
+// These defaults should match the defaults in
+// java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+// NOTE: There are some settings that don't apply to PolyGerrit
+// (Render mode being at least one of them).
+export function createDefaultDiffPrefs(): DiffPreferencesInfo {
+  return {
+    context: 10,
+    cursor_blink_rate: 0,
+    font_size: 12,
+    ignore_whitespace: 'IGNORE_NONE',
+    line_length: 100,
+    line_wrapping: false,
+    show_line_endings: true,
+    show_tabs: true,
+    show_whitespace_errors: true,
+    syntax_highlighting: true,
+    tab_size: 8,
+  };
+}
+
+// These defaults should match the defaults in
+// java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+export function createDefaultEditPrefs(): EditPreferencesInfo {
+  return {
+    auto_close_brackets: false,
+    cursor_blink_rate: 0,
+    hide_line_numbers: false,
+    hide_top_menu: false,
+    indent_unit: 2,
+    indent_with_tabs: false,
+    key_map_type: 'DEFAULT',
+    line_length: 100,
+    line_wrapping: false,
+    match_brackets: true,
+    show_base: false,
+    show_tabs: true,
+    show_whitespace_errors: true,
+    syntax_highlighting: true,
+    tab_size: 8,
+    theme: 'DEFAULT',
+  };
+}
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
new file mode 100644
index 0000000..0738825
--- /dev/null
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -0,0 +1,92 @@
+/**
+ * @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 enum LifeCycle {
+  PLUGIN_LIFE_CYCLE = 'Plugin life cycle',
+  STARTED_AS_USER = 'Started as user',
+  STARTED_AS_GUEST = 'Started as guest',
+  VISIBILILITY_HIDDEN = 'Visibility changed to hidden',
+  VISIBILILITY_VISIBLE = 'Visibility changed to visible',
+  EXTENSION_DETECTED = 'Extension detected',
+  PLUGINS_INSTALLED = 'Plugins installed',
+  USER_REFERRED_FROM = 'User referred from',
+}
+
+export enum Execution {
+  PLUGIN_API = 'plugin-api',
+  REACHABLE_CODE = 'reachable code',
+  METHOD_USED = 'method used',
+}
+
+export enum Timing {
+  // Time between the navigationStart timing and the ready call of gr-app.
+  APP_STARTED = 'App Started',
+  // Time from navigation to showing first content of change view.
+  CHANGE_DISPLAYED = 'ChangeDisplayed',
+  // Time from navigation to having loaded and presented all change data.
+  CHANGE_LOAD_FULL = 'ChangeFullyLoaded',
+  // Time from navigation to showing content of dashboard.
+  DASHBOARD_DISPLAYED = 'DashboardDisplayed',
+  // Time from navigation to showing full content of diff without highlighting layer
+  DIFF_VIEW_CONTENT_DISPLAYED = 'DiffViewOnlyContent',
+  // Time from navigation to showing viewport (> 120 lines) of diff with highlighting layer.
+  DIFF_VIEW_DISPLAYED = 'DiffViewDisplayed',
+  // Time from navigation to showing full content of diff
+  DIFF_VIEW_LOAD_FULL = 'DiffViewFullyLoaded',
+  // Time from navigation to showing initial content of the file list.
+  FILE_LIST_DISPLAYED = 'FileListDisplayed',
+  // Time from startup to having loaded all plugins.
+  PLUGINS_LOADED = 'PluginsLoaded',
+  // Time from startup to having loaded metrics plugin
+  METRICS_PLUGIN_LOADED = 'MetricsPluginLoaded',
+  // Time from startup to showing first content of change view.
+  STARTUP_CHANGE_DISPLAYED = 'StartupChangeDisplayed',
+  // Time from startup to having loaded and presented all change data.
+  STARTUP_CHANGE_LOAD_FULL = 'StartupChangeFullyLoaded',
+  // Time from startup to showing content of the dashboard.
+  STARTUP_DASHBOARD_DISPLAYED = 'StartupDashboardDisplayed',
+  // Time from startup to showing full content of diff without highlighting layer
+  STARTUP_DIFF_VIEW_CONTENT_DISPLAYED = 'StartupDiffViewOnlyContent',
+  // Time from startup to showing viewport (> 120 lines) of diff with highlighting layer.
+  STARTUP_DIFF_VIEW_DISPLAYED = 'StartupDiffViewDisplayed',
+  // Time from startup to showing full content of diff view.
+  STARTUP_DIFF_VIEW_LOAD_FULL = 'StartupDiffViewFullyLoaded',
+  // Time from startup to showing initial content of the file list.
+  STARTUP_FILE_LIST_DISPLAYED = 'StartupFileListDisplayed',
+  // Time from startup to when the webcomponentsready event is fired. If the event is fired from the webcomponents-lite polyfill, this may be arbitrarily long after the app has started.
+  WEB_COMPONENTS_READY = 'WebComponentsReady',
+  // Time to received all data for change view
+  CHANGE_DATA = 'ChangeDataLoaded',
+  // Time to compute and render first content of change view
+  CHANGE_RELOAD = 'ChangeReloaded',
+  // Time from clicking the [Send] button of the Reply Dialog to the time that the change has reloaded (core data)
+  SEND_REPLY = 'SendReply',
+  // The overall time to render a diff (excluding loading of data).
+  DIFF_TOTAL = 'Diff Total Render',
+  // The time to render the content off a diff (excluding loading of data or syntax highlighting).
+  DIFF_CONTENT = 'Diff Content Render',
+  // Time to compute and render the syntax highlighting of a diff.
+  DIFF_SYNTAX = 'Diff Syntax Render',
+  // Time to render a batch of rows in the file list. If there are very many files, this may be the first batch of rows that are rendered by default. If there are many files and the user clicks [Show More], this may be the batch of additional files that appear as a result.
+  FILE_RENDER = 'FileListRenderTime',
+  // This measures the same interval as FileListRenderTime, but the result is divided by the number of rows in the batch.
+  FILE_RENDER_AVG = 'FileListRenderTimePerFile',
+  // The time to expand some number of diffs in the file list (i.e. render their diffs, including syntax highlighting).
+  FILE_EXPAND_ALL = 'ExpandAllDiffs',
+  // This measures the same interval as ExpandAllDiffs, but the result is divided by the number of diffs expanded.
+  FILE_EXPAND_ALL_AVG = 'ExpandAllPerDiff',
+}
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 c52a04c..a793d13 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -19,11 +19,8 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-permission/gr-permission';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {htmlTemplate} from './gr-access-section_html';
 import {
   AccessPermissions,
@@ -44,6 +41,7 @@
   RepoName,
 } from '../../../types/common';
 import {PolymerDomRepeatEvent} from '../../../types/types';
+import {fireEvent} from '../../../utils/event-util';
 
 /**
  * Fired when the section has been modified or removed.
@@ -72,9 +70,7 @@
 }
 
 @customElement('gr-access-section')
-export class GrAccessSection extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAccessSection extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -115,9 +111,8 @@
   @property({type: Array})
   _permissions?: PermissionArray<EditablePermissionInfo>;
 
-  /** @override */
-  created() {
-    super.created();
+  constructor() {
+    super();
     this.addEventListener('access-saved', () => this._handleAccessSaved());
   }
 
@@ -145,9 +140,7 @@
       // For a new section, this is not fired because new permissions and
       // rules have to be added in order to save, modifying the ref is not
       // enough.
-      this.dispatchEvent(
-        new CustomEvent('access-modified', {bubbles: true, composed: true})
-      );
+      fireEvent(this, 'access-modified');
     }
     this.section.value.updatedId = this.section.id;
   }
@@ -280,18 +273,11 @@
       return;
     }
     if (this.section.value.added) {
-      this.dispatchEvent(
-        new CustomEvent('added-section-removed', {
-          bubbles: true,
-          composed: true,
-        })
-      );
+      fireEvent(this, 'added-section-removed');
     }
     this._deleted = true;
     this.section.value.deleted = true;
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _handleUndoRemove() {
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
index 4f5a06e..2821632 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
@@ -154,5 +154,4 @@
     </div>
     <!-- end deletedContainer -->
   </fieldset>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index 9d40e28..37a2f3e 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -20,10 +20,7 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-create-group-dialog/gr-create-group-dialog';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 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';
@@ -32,8 +29,9 @@
 import {AppElementAdminParams} from '../../gr-app-types';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
+import {fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -45,14 +43,11 @@
   $: {
     createOverlay: GrOverlay;
     createNewModal: GrCreateGroupDialog;
-    restAPI: RestApiService & Element;
   };
 }
 
 @customElement('gr-admin-group-list')
-export class GrAdminGroupList extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrAdminGroupList extends ListViewMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -96,17 +91,13 @@
   @property({type: String})
   _filter = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._getCreateGroupCapability();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Groups'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Groups');
     this._maybeOpenCreateOverlay(this.params);
   }
 
@@ -136,11 +127,11 @@
   }
 
   _getCreateGroupCapability() {
-    return this.$.restAPI.getAccount().then(account => {
+    return this.restApiService.getAccount().then(account => {
       if (!account) {
         return;
       }
-      return this.$.restAPI
+      return this.restApiService
         .getAccountCapabilities(['createGroup'])
         .then(capabilities => {
           if (capabilities?.createGroup) {
@@ -152,7 +143,7 @@
 
   _getGroups(filter: string, groupsPerPage: number, offset?: number) {
     this._groups = [];
-    return this.$.restAPI
+    return this.restApiService
       .getGroups(filter, groupsPerPage, offset)
       .then(groups => {
         if (!groups) {
@@ -168,7 +159,7 @@
   }
 
   _refreshGroupsList() {
-    this.$.restAPI.invalidateGroupsCache();
+    this.restApiService.invalidateGroupsCache();
     return this._getGroups(this._filter, this._groupsPerPage, this._offset);
   }
 
@@ -183,7 +174,9 @@
   }
 
   _handleCreateClicked() {
-    this.$.createOverlay.open();
+    this.$.createOverlay.open().then(() => {
+      this.$.createNewModal.focus();
+    });
   }
 
   _visibleToAll(item: GroupInfo) {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
index 93de8b4..91863a9 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
@@ -67,9 +67,7 @@
       on-confirm="_handleCreateGroup"
       on-cancel="_handleCloseCreate"
     >
-      <div class="header" slot="header">
-        Create Group
-      </div>
+      <div class="header" slot="header">Create Group</div>
       <div class="main" slot="main">
         <gr-create-group-dialog
           has-new-group-name="{{_hasNewGroupName}}"
@@ -78,5 +76,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
index 93d41c3..2df1ac6 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
@@ -19,6 +19,7 @@
 import './gr-admin-group-list.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import 'lodash/lodash.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-admin-group-list');
 
@@ -73,24 +74,16 @@
   });
 
   suite('list with groups', () => {
-    setup(done => {
+    setup(async () => {
       groups = _.times(26, groupGenerator);
-
-      stub('gr-rest-api-interface', {
-        getGroups(num, offset) {
-          return Promise.resolve(groups);
-        },
-      });
-
-      element._paramsChanged(value).then(() => { flush(done); });
+      stubRestApi('getGroups').returns(Promise.resolve(groups));
+      element._paramsChanged(value);
+      await flush();
     });
 
-    test('test for test group in the list', done => {
-      flush(() => {
-        assert.equal(element._groups[1].name, '1');
-        assert.equal(element._groups[1].options.visible_to_all, false);
-        done();
-      });
+    test('test for test group in the list', () => {
+      assert.equal(element._groups[1].name, '1');
+      assert.equal(element._groups[1].options.visible_to_all, false);
     });
 
     test('_shownGroups', () => {
@@ -113,13 +106,7 @@
   suite('test with less then 25 groups', () => {
     setup(done => {
       groups = _.times(25, groupGenerator);
-
-      stub('gr-rest-api-interface', {
-        getGroups(num, offset) {
-          return Promise.resolve(groups);
-        },
-      });
-
+      stubRestApi('getGroups').returns(Promise.resolve(groups));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -129,20 +116,15 @@
   });
 
   suite('filter', () => {
-    test('_paramsChanged', done => {
-      sinon.stub(
-          element.$.restAPI,
-          'getGroups')
-          .callsFake(() => Promise.resolve(groups));
+    test('_paramsChanged', async () => {
+      const getGroupsStub = stubRestApi('getGroups');
+      getGroupsStub.returns(Promise.resolve(groups));
       const value = {
         filter: 'test',
         offset: 25,
       };
-      element._paramsChanged(value).then(() => {
-        assert.isTrue(element.$.restAPI.getGroups.lastCall
-            .calledWithExactly('test', 25, 25));
-        done();
-      });
+      await element._paramsChanged(value);
+      assert.isTrue(getGroupsStub.lastCall.calledWithExactly('test', 25, 25));
     });
   });
 
@@ -173,7 +155,8 @@
     });
 
     test('_handleCreateClicked opens modal', () => {
-      const openStub = sinon.stub(element.$.createOverlay, 'open');
+      const openStub = sinon.stub(element.$.createOverlay, 'open').returns(
+          Promise.resolve());
       element._handleCreateClicked();
       assert.isTrue(openStub.called);
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 7fb713f..f8ab519 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -19,9 +19,7 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
 import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import '../../shared/gr-page-nav/gr-page-nav';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-admin-group-list/gr-admin-group-list';
 import '../gr-group/gr-group';
 import '../gr-group-audit-log/gr-group-audit-log';
@@ -33,14 +31,11 @@
 import '../gr-repo-dashboards/gr-repo-dashboards';
 import '../gr-repo-detail-list/gr-repo-detail-list';
 import '../gr-repo-list/gr-repo-list';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-admin-view_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {
   GerritNav,
-  GerritView,
   GroupDetailView,
   RepoDetailView,
 } from '../../core/gr-navigation/gr-navigation';
@@ -52,7 +47,6 @@
   SubsectionInterface,
 } from '../../../utils/admin-nav-util';
 import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   AppElementAdminParams,
   AppElementGroupParams,
@@ -66,17 +60,11 @@
 } from '../../../types/common';
 import {GroupNameChangedDetail} from '../gr-group/gr-group';
 import {ValueChangeDetail} from '../../shared/gr-dropdown-list/gr-dropdown-list';
-import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+import {appContext} from '../../../services/app-context';
+import {GerritView} from '../../../services/router/router-model';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
-export interface GrAdminView {
-  $: {
-    restAPI: RestApiService & Element;
-    jsAPI: GrJsApiInterface;
-  };
-}
-
 interface AdminSubsectionLink {
   text: string;
   value: string;
@@ -102,14 +90,12 @@
 }
 
 @customElement('gr-admin-view')
-export class GrAdminView extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAdminView extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
 
-  private _account?: AccountDetailInfo;
+  private account?: AccountDetailInfo;
 
   @property({type: Object})
   params?: AdminViewParams;
@@ -183,19 +169,23 @@
   @property({type: Boolean})
   _showPluginList?: boolean;
 
+  private readonly restApiService = appContext.restApiService;
+
+  private readonly jsAPI = appContext.jsApiService;
+
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.reload();
   }
 
   reload() {
     const promises: [Promise<AccountDetailInfo | undefined>, Promise<void>] = [
-      this.$.restAPI.getAccount(),
+      this.restApiService.getAccount(),
       getPluginLoader().awaitPluginsLoaded(),
     ];
     return Promise.all(promises).then(result => {
-      this._account = result[0];
+      this.account = result[0];
       let options: AdminNavLinksOption | undefined = undefined;
       if (this._repoName) {
         options = {repoName: this._repoName};
@@ -210,15 +200,15 @@
       }
 
       return getAdminLinks(
-        this._account,
+        this.account,
         () =>
-          this.$.restAPI.getAccountCapabilities().then(capabilities => {
+          this.restApiService.getAccountCapabilities().then(capabilities => {
             if (!capabilities) {
               throw new Error('getAccountCapabilities returns undefined');
             }
             return capabilities;
           }),
-        () => this.$.jsAPI.getAdminMenuLinks(),
+        () => this.jsAPI.getAdminMenuLinks(),
         options
       ).then(res => {
         this._filteredLinks = res.links;
@@ -406,7 +396,7 @@
       }
       return '';
     }
-    // TODO(TS): The following condtion seems always false, because params
+    // TODO(TS): The following condition seems always false, because params
     // never has detailType property. Remove it.
     if (
       ((params as unknown) as AdminSubsectionLink).detailType &&
@@ -423,7 +413,7 @@
     if (!groupId) return;
 
     const promises: Array<Promise<void>> = [];
-    this.$.restAPI.getGroupConfig(groupId).then(group => {
+    this.restApiService.getGroupConfig(groupId).then(group => {
       if (!group || !group.name) {
         return;
       }
@@ -433,13 +423,13 @@
       this.reload();
 
       promises.push(
-        this.$.restAPI.getIsAdmin().then(isAdmin => {
+        this.restApiService.getIsAdmin().then(isAdmin => {
           this._isAdmin = !!isAdmin;
         })
       );
 
       promises.push(
-        this.$.restAPI.getIsGroupOwner(group.name).then(isOwner => {
+        this.restApiService.getIsGroupOwner(group.name).then(isOwner => {
           this._groupOwner = isOwner;
         })
       );
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts
index 5e85a93..f073a9f 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts
@@ -51,7 +51,7 @@
     .selectText.show {
       display: inline-block;
     }
-    main.breadcrumbs:not(.table) {
+    .main.breadcrumbs:not(.table) {
       margin-top: var(--spacing-l);
     }
   </style>
@@ -114,70 +114,68 @@
     </section>
   </template>
   <template is="dom-if" if="[[_showRepoList]]" restamp="true">
-    <main class="table">
+    <div class="main table">
       <gr-repo-list class="table" params="[[params]]"></gr-repo-list>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showGroupList]]" restamp="true">
-    <main class="table">
+    <div class="main table">
       <gr-admin-group-list class="table" params="[[params]]">
       </gr-admin-group-list>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showPluginList]]" restamp="true">
-    <main class="table">
+    <div class="main table">
       <gr-plugin-list class="table" params="[[params]]"></gr-plugin-list>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showRepoMain]]" restamp="true">
-    <main class="breadcrumbs">
+    <div class="main breadcrumbs">
       <gr-repo repo="[[params.repo]]"></gr-repo>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showGroup]]" restamp="true">
-    <main class="breadcrumbs">
+    <div class="main breadcrumbs">
       <gr-group
         group-id="[[params.groupId]]"
         on-name-changed="_updateGroupName"
       ></gr-group>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showGroupMembers]]" restamp="true">
-    <main class="breadcrumbs">
+    <div class="main breadcrumbs">
       <gr-group-members group-id="[[params.groupId]]"></gr-group-members>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showRepoDetailList]]" restamp="true">
-    <main class="table breadcrumbs">
+    <div class="main table breadcrumbs">
       <gr-repo-detail-list
         params="[[params]]"
         class="table"
       ></gr-repo-detail-list>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showGroupAuditLog]]" restamp="true">
-    <main class="table breadcrumbs">
+    <div class="main table breadcrumbs">
       <gr-group-audit-log
         group-id="[[params.groupId]]"
         class="table"
       ></gr-group-audit-log>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showRepoCommands]]" restamp="true">
-    <main class="breadcrumbs">
+    <div class="main breadcrumbs">
       <gr-repo-commands repo="[[params.repo]]"></gr-repo-commands>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showRepoAccess]]" restamp="true">
-    <main class="breadcrumbs">
+    <div class="main breadcrumbs">
       <gr-repo-access path="[[path]]" repo="[[params.repo]]"></gr-repo-access>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showRepoDashboards]]" restamp="true">
-    <main class="table breadcrumbs">
+    <div class="main table breadcrumbs">
       <gr-repo-dashboards repo="[[params.repo]]"></gr-repo-dashboards>
-    </main>
+    </div>
   </template>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
index 44fd4d6..69c218c 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
@@ -18,22 +18,27 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-admin-view.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
+import {stubBaseUrl, stubRestApi} from '../../../test/test-utils.js';
+import {GerritView} from '../../../services/router/router-model.js';
 
 const basicFixture = fixtureFromElement('gr-admin-view');
 
+function createAdminCapabilities() {
+  return {
+    createGroup: true,
+    createProject: true,
+    viewPlugins: true,
+  };
+}
+
 suite('gr-admin-view tests', () => {
   let element;
 
   setup(done => {
     element = basicFixture.instantiate();
-    stub('gr-rest-api-interface', {
-      getProjectConfig() {
-        return Promise.resolve({});
-      },
-    });
+    stubRestApi('getProjectConfig').returns(Promise.resolve({}));
     const pluginsLoaded = Promise.resolve();
     sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
     pluginsLoaded.then(() => flush(done));
@@ -83,18 +88,11 @@
   });
 
   test('_filteredLinks admin', done => {
-    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+    stubRestApi('getAccount').returns(Promise.resolve({
       name: 'test-user',
     }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        })
-        );
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
     element.reload().then(() => {
       assert.equal(element._filteredLinks.length, 3);
 
@@ -110,39 +108,26 @@
     });
   });
 
-  test('_filteredLinks non admin authenticated', done => {
-    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
-      name: 'test-user',
-    }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({})
-        );
-    element.reload().then(() => {
-      assert.equal(element._filteredLinks.length, 2);
-
-      // Repos
-      assert.isNotOk(element._filteredLinks[0].subsection);
-
-      // Groups
-      assert.isNotOk(element._filteredLinks[0].subsection);
-      done();
-    });
+  test('_filteredLinks non admin authenticated', async () => {
+    await element.reload();
+    assert.equal(element._filteredLinks.length, 2);
+    // Repos
+    assert.isNotOk(element._filteredLinks[0].subsection);
+    // Groups
+    assert.isNotOk(element._filteredLinks[0].subsection);
   });
 
-  test('_filteredLinks non admin unathenticated', done => {
-    element.reload().then(() => {
-      assert.equal(element._filteredLinks.length, 1);
-
-      // Repos
-      assert.isNotOk(element._filteredLinks[0].subsection);
-      done();
-    });
+  test('_filteredLinks non admin unathenticated', async () => {
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    await element.reload();
+    assert.equal(element._filteredLinks.length, 1);
+    // Repos
+    assert.isNotOk(element._filteredLinks[0].subsection);
   });
 
   test('_filteredLinks from plugin', () => {
-    sinon.stub(element.$.jsAPI, 'getAdminMenuLinks').returns([
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    sinon.stub(element.jsAPI, 'getAdminMenuLinks').returns([
       {text: 'internal link text', url: '/internal/link/url'},
       {text: 'external link text', url: 'http://external/link/url'},
     ]);
@@ -171,17 +156,11 @@
 
   test('Repo shows up in nav', done => {
     element._repoName = 'Test Repo';
-    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+    stubRestApi('getAccount').returns(Promise.resolve({
       name: 'test-user',
     }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
     element.reload().then(() => {
       flush();
       assert.equal(dom(element.root)
@@ -196,53 +175,31 @@
     });
   });
 
-  test('Group shows up in nav', done => {
+  test('Group shows up in nav', async () => {
     element._groupId = 'a15262';
     element._groupName = 'my-group';
     element._groupIsInternal = true;
     element._isAdmin = true;
     element._groupOwner = false;
-    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
-      name: 'test-user',
-    }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    element.reload().then(() => {
-      flush();
-      assert.equal(element._filteredLinks.length, 3);
-
-      // Repos
-      assert.isNotOk(element._filteredLinks[0].subsection);
-
-      // Groups
-      assert.equal(element._filteredLinks[1].subsection.children.length, 2);
-      assert.equal(element._filteredLinks[1].subsection.name, 'my-group');
-
-      // Plugins
-      assert.isNotOk(element._filteredLinks[2].subsection);
-      done();
-    });
+    stubRestApi('getAccount').returns(Promise.resolve({name: 'test-user'}));
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
+    await element.reload();
+    await flush();
+    assert.equal(element._filteredLinks.length, 3);
+    // Repos
+    assert.isNotOk(element._filteredLinks[0].subsection);
+    // Groups
+    assert.equal(element._filteredLinks[1].subsection.children.length, 2);
+    assert.equal(element._filteredLinks[1].subsection.name, 'my-group');
+    // Plugins
+    assert.isNotOk(element._filteredLinks[2].subsection);
   });
 
   test('Nav is reloaded when repo changes', () => {
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccount')
-        .callsFake(() => Promise.resolve({_id: 1}));
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
+    stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
     sinon.stub(element, 'reload');
     element.params = {repo: 'Test Repo', view: GerritView.REPO};
     assert.equal(element.reload.callCount, 1);
@@ -253,18 +210,9 @@
 
   test('Nav is reloaded when group changes', () => {
     sinon.stub(element, '_computeGroupName');
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccount')
-        .callsFake(() => Promise.resolve({_id: 1}));
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
+    stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
     sinon.stub(element, 'reload');
     element.params = {groupId: '1', view: GerritView.GROUP};
     assert.equal(element.reload.callCount, 1);
@@ -320,18 +268,9 @@
       view: GerritNav.View.REPO,
       detail: GerritNav.RepoDetailView.ACCESS,
     };
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccount')
-        .callsFake(() => Promise.resolve({_id: 1}));
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
+    stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
     flush();
     const expectedFilteredLinks = [
       {
@@ -483,27 +422,15 @@
 
   suite('_computeSelectedClass', () => {
     setup(() => {
-      sinon.stub(
-          element.$.restAPI,
-          'getAccountCapabilities')
-          .callsFake(() => Promise.resolve({
-            createGroup: true,
-            createProject: true,
-            viewPlugins: true,
-          }));
-      sinon.stub(
-          element.$.restAPI,
-          'getAccount')
-          .callsFake(() => Promise.resolve({_id: 1}));
-
+      stubRestApi('getAccountCapabilities').returns(
+          Promise.resolve(createAdminCapabilities()));
+      stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
       return element.reload();
     });
 
     suite('repos', () => {
       setup(() => {
-        stub('gr-repo-access', {
-          _repoChanged: () => {},
-        });
+        stub('gr-repo-access', '_repoChanged').callsFake(() => {});
       });
 
       test('repo list', () => {
@@ -568,20 +495,17 @@
     });
 
     suite('groups', () => {
+      let getGroupConfigStub;
       setup(() => {
-        stub('gr-group', {
-          _loadGroup: () => Promise.resolve({}),
-        });
-        stub('gr-group-members', {
-          _loadGroupDetails: () => {},
-        });
+        stub('gr-group', '_loadGroup').callsFake(() => Promise.resolve({}));
+        stub('gr-group-members', '_loadGroupDetails').callsFake(() => {});
 
-        sinon.stub(element.$.restAPI, 'getGroupConfig')
-            .returns(Promise.resolve({
-              name: 'foo',
-              id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
-            }));
-        sinon.stub(element.$.restAPI, 'getIsGroupOwner')
+        getGroupConfigStub = stubRestApi('getGroupConfig');
+        getGroupConfigStub.returns(Promise.resolve({
+          name: 'foo',
+          id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
+        }));
+        stubRestApi('getIsGroupOwner')
             .returns(Promise.resolve(true));
         return element.reload();
       });
@@ -619,12 +543,10 @@
       });
 
       test('external group', () => {
-        element.$.restAPI.getGroupConfig.restore();
-        sinon.stub(element.$.restAPI, 'getGroupConfig')
-            .returns(Promise.resolve({
-              name: 'foo',
-              id: 'external-id',
-            }));
+        getGroupConfigStub.returns(Promise.resolve({
+          name: 'foo',
+          id: 'external-id',
+        }));
         element.params = {
           view: GerritNav.View.GROUP,
           groupId: 1234,
diff --git a/polygerrit-ui/app/elements/admin/gr-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 79a3e95..d545b4c 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
@@ -16,8 +16,6 @@
  */
 import '../../shared/gr-dialog/gr-dialog';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-delete-item-dialog_html';
 import {customElement, property} from '@polymer/decorators';
@@ -36,9 +34,7 @@
 }
 
 @customElement('gr-confirm-delete-item-dialog')
-export class GrConfirmDeleteItemDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrConfirmDeleteItemDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
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
index ce9ac9c..890d345 100644
--- 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
@@ -37,9 +37,7 @@
         Do you really want to delete the following
         [[_computeItemName(itemType)]]?
       </label>
-      <div>
-        [[item]]
-      </div>
+      <div>[[item]]</div>
     </div>
   </gr-dialog>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index 2a6c7a8..f37e6a3 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -19,10 +19,7 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-create-change-dialog_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
@@ -35,17 +32,15 @@
   InheritedBooleanInfo,
 } from '../../../types/common';
 import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
-import {hasOwnProperty} from '../../../utils/common-util';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {appContext} from '../../../services/app-context';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
 
 export interface GrCreateChangeDialog {
   $: {
-    restAPI: RestApiService & Element;
     privateChangeCheckBox: HTMLInputElement;
     branchInput: GrAutocomplete;
     tagNameInput: HTMLInputElement;
@@ -53,9 +48,7 @@
   };
 }
 @customElement('gr-create-change-dialog')
-export class GrCreateChangeDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCreateChangeDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -93,14 +86,16 @@
   @property({type: Boolean})
   _privateChangesEnabled?: boolean;
 
+  restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = (input: string) => this._getRepoBranchesSuggestions(input);
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     if (!this.repoName) {
       return Promise.resolve();
     }
@@ -108,14 +103,14 @@
     const promises = [];
 
     promises.push(
-      this.$.restAPI.getProjectConfig(this.repoName).then(config => {
+      this.restApiService.getProjectConfig(this.repoName).then(config => {
         if (!config) return;
         this.privateByDefault = config.private_by_default;
       })
     );
 
     promises.push(
-      this.$.restAPI.getConfig().then(config => {
+      this.restApiService.getConfig().then(config => {
         if (!config) {
           return;
         }
@@ -143,7 +138,7 @@
     }
     const isPrivate = this.$.privateChangeCheckBox.checked;
     const isWip = true;
-    return this.$.restAPI
+    return this.restApiService
       .createChange(
         this.repoName,
         this.branch,
@@ -169,24 +164,17 @@
     if (input.startsWith(REF_PREFIX)) {
       input = input.substring(REF_PREFIX.length);
     }
-    return this.$.restAPI
+    return this.restApiService
       .getRepoBranches(input, this.repoName, SUGGESTIONS_LIMIT)
       .then(response => {
         if (!response) return [];
         const branches = [];
-        let branch;
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
+        for (const branchInfo of response) {
+          let name: string = branchInfo.ref;
+          if (name.startsWith('refs/heads/')) {
+            name = name.substring('refs/heads/'.length);
           }
-          if (response[key].ref.startsWith('refs/heads/')) {
-            branch = response[key].ref.substring('refs/heads/'.length);
-          } else {
-            branch = response[key].ref;
-          }
-          branches.push({
-            name: branch,
-          });
+          branches.push({name});
         }
         return branches;
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts
index 77e2c3b..47f3818 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts
@@ -113,5 +113,4 @@
       </span>
     </section>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
index e529730..8a4cbbe 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
@@ -15,12 +15,13 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-create-change-dialog.js';
+import '../../../test/common-test-setup-karma';
+import './gr-create-change-dialog';
 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 {stubRestApi} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-create-change-dialog');
 
@@ -28,23 +29,18 @@
   let element: GrCreateChangeDialog;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() {
-        return Promise.resolve(true);
-      },
-      getRepoBranches(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve([
-            {
-              ref: 'refs/heads/test-branch' as GitRef,
-              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-              can_delete: true,
-            },
-          ]);
-        } else {
-          return Promise.resolve([]);
-        }
-      },
+    stubRestApi('getRepoBranches').callsFake((input: string) => {
+      if (input.startsWith('test')) {
+        return Promise.resolve([
+          {
+            ref: 'refs/heads/test-branch' as GitRef,
+            revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+            can_delete: true,
+          },
+        ]);
+      } else {
+        return Promise.resolve([]);
+      }
     });
     element = basicFixture.instantiate();
     element.repoName = 'test-repo' as RepoName;
@@ -67,9 +63,9 @@
       work_in_progress: true,
     };
 
-    const saveStub = sinon
-      .stub(element.$.restAPI, 'createChange')
-      .callsFake(() => Promise.resolve(createChange()));
+    const saveStub = stubRestApi('createChange').returns(
+      Promise.resolve(createChange())
+    );
 
     element.branch = 'test-branch' as BranchName;
     element.topic = 'test-topic';
@@ -103,9 +99,9 @@
       work_in_progress: true,
     };
 
-    const saveStub = sinon
-      .stub(element.$.restAPI, 'createChange')
-      .callsFake(() => Promise.resolve(createChange()));
+    const saveStub = stubRestApi('createChange').returns(
+      Promise.resolve(createChange())
+    );
 
     element.branch = 'test-branch' as BranchName;
     element.topic = 'test-topic';
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
index 1d8b7db..b4f07cb 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -17,27 +17,16 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-create-group-dialog_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
 import {customElement, property, observe} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GroupName} from '../../../types/common';
-
-export interface GrCreateGroupDialog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
 
 @customElement('gr-create-group-dialog')
-export class GrCreateGroupDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCreateGroupDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -51,6 +40,8 @@
   @property({type: Boolean})
   _groupCreated = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   _computeGroupUrl(groupId: string) {
     return getBaseUrl() + '/admin/groups/' + encodeURL(groupId, true);
   }
@@ -60,14 +51,18 @@
     this.hasNewGroupName = !!name;
   }
 
+  focus() {
+    this.shadowRoot?.querySelector('input')?.focus();
+  }
+
   handleCreateGroup() {
     const name = this._name as GroupName;
-    return this.$.restAPI.createGroup({name}).then(groupRegistered => {
+    return this.restApiService.createGroup({name}).then(groupRegistered => {
       if (groupRegistered.status !== 201) {
         return;
       }
       this._groupCreated = true;
-      return this.$.restAPI.getGroupConfig(name).then(group => {
+      return this.restApiService.getGroupConfig(name).then(group => {
         // TODO(TS): should group always defined ?
         page.show(this._computeGroupUrl(group!.group_id!));
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts
index d4ecc5d..daf8780 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts
@@ -38,5 +38,4 @@
       </section>
     </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
index d32ff30..af33691 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-create-group-dialog.js';
 import {page} from '../../../utils/page-wrapper-utils.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-create-group-dialog');
 
@@ -27,9 +28,6 @@
   const GROUP_NAME = 'test-group';
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-    });
     element = basicFixture.instantiate();
   });
 
@@ -47,11 +45,8 @@
   });
 
   test('test for redirecting to group on successful creation', done => {
-    sinon.stub(element.$.restAPI, 'createGroup')
-        .returns(Promise.resolve({status: 201}));
-
-    sinon.stub(element.$.restAPI, 'getGroupConfig')
-        .returns(Promise.resolve({group_id: 551}));
+    stubRestApi('createGroup').returns(Promise.resolve({status: 201}));
+    stubRestApi('getGroupConfig').returns(Promise.resolve({group_id: 551}));
 
     const showStub = sinon.stub(page, 'show');
     element.handleCreateGroup()
@@ -62,11 +57,8 @@
   });
 
   test('test for unsuccessful group creation', done => {
-    sinon.stub(element.$.restAPI, 'createGroup')
-        .returns(Promise.resolve({status: 409}));
-
-    sinon.stub(element.$.restAPI, 'getGroupConfig')
-        .returns(Promise.resolve({group_id: 551}));
+    stubRestApi('createGroup').returns(Promise.resolve({status: 409}));
+    stubRestApi('getGroupConfig').returns(Promise.resolve({group_id: 551}));
 
     const showStub = sinon.stub(page, 'show');
     element.handleCreateGroup()
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
index e0a5042..8875ad4 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
@@ -18,33 +18,22 @@
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-create-pointer-dialog_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
 import {customElement, property, observe} from '@polymer/decorators';
 import {BranchName, RepoName} from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
 enum DetailType {
   branches = 'branches',
   tags = 'tags',
 }
 
-export interface GrCreatePointerDialog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 @customElement('gr-create-pointer-dialog')
-export class GrCreatePointerDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCreatePointerDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -75,6 +64,8 @@
     this.hasNewItemName = !!name;
   }
 
+  private readonly restApiService = appContext.restApiService;
+
   handleCreateItem() {
     if (!this.repoName) {
       throw new Error('repoName name is not set');
@@ -85,7 +76,7 @@
     const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
     const url = `${getBaseUrl()}/admin/repos/${encodeURL(this.repoName, true)}`;
     if (this.itemDetail === DetailType.branches) {
-      return this.$.restAPI
+      return this.restApiService
         .createRepoBranch(this.repoName, this._itemName, {revision: USE_HEAD})
         .then(itemRegistered => {
           if (itemRegistered.status === 201) {
@@ -93,7 +84,7 @@
           }
         });
     } else if (this.itemDetail === DetailType.tags) {
-      return this.$.restAPI
+      return this.restApiService
         .createRepoTag(this.repoName, this._itemName, {
           revision: USE_HEAD,
           message: this._itemAnnotation || undefined,
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts
index 0b3d81ae..452aab7 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts
@@ -80,5 +80,4 @@
       </section>
     </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
index 79a18d5..60af4d5 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-create-pointer-dialog.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-create-pointer-dialog');
 
@@ -28,17 +29,11 @@
   };
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-    });
     element = basicFixture.instantiate();
   });
 
   test('branch created', done => {
-    sinon.stub(
-        element.$.restAPI,
-        'createRepoBranch')
-        .callsFake(() => Promise.resolve({}));
+    stubRestApi('createRepoBranch').returns(Promise.resolve({}));
 
     assert.isFalse(element.hasNewItemName);
 
@@ -57,10 +52,7 @@
   });
 
   test('tag created', done => {
-    sinon.stub(
-        element.$.restAPI,
-        'createRepoTag')
-        .callsFake(() => Promise.resolve({}));
+    stubRestApi('createRepoTag').returns(Promise.resolve({}));
 
     assert.isFalse(element.hasNewItemName);
 
@@ -79,10 +71,7 @@
   });
 
   test('tag created with annotations', done => {
-    sinon.stub(
-        element.$.restAPI,
-        'createRepoTag')
-        .callsFake(() => Promise.resolve({}));
+    stubRestApi('createRepoTag').returns(() => Promise.resolve({}));
 
     assert.isFalse(element.hasNewItemName);
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
index 6f0ac19..94a4b0a 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -19,19 +19,20 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-create-repo-dialog_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
 import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {ProjectInput, RepoName} from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {
+  BranchName,
+  GroupId,
+  ProjectInput,
+  RepoName,
+} from '../../../types/common';
 import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -39,16 +40,8 @@
   }
 }
 
-export interface GrCreateRepoDialog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 @customElement('gr-create-repo-dialog')
-export class GrCreateRepoDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCreateRepoDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -61,8 +54,12 @@
     create_empty_commit: true,
     permissions_only: false,
     name: '' as RepoName,
+    branches: [],
   };
 
+  @property({type: String})
+  _defaultBranch?: BranchName;
+
   @property({type: Boolean})
   _repoCreated = false;
 
@@ -70,7 +67,7 @@
   _repoOwner?: string;
 
   @property({type: String})
-  _repoOwnerId?: string;
+  _repoOwnerId?: GroupId;
 
   @property({type: Object})
   _query: AutocompleteQuery;
@@ -78,6 +75,8 @@
   @property({type: Object})
   _queryGroups: AutocompleteQuery;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = (input: string) => this._getRepoSuggestions(input);
@@ -88,56 +87,43 @@
     return getBaseUrl() + '/admin/repos/' + encodeURL(repoName, true);
   }
 
+  focus() {
+    this.shadowRoot?.querySelector('input')?.focus();
+  }
+
   @observe('_repoConfig.name')
   _updateRepoName(name: string) {
     this.hasNewRepoName = !!name;
   }
 
-  @observe('_repoOwnerId')
-  _repoOwnerIdUpdate(id?: string) {
-    if (id) {
-      this.set('_repoConfig.owners', [id]);
-    } else {
-      this.set('_repoConfig.owners', undefined);
-    }
-  }
-
   handleCreateRepo() {
-    return this.$.restAPI.createRepo(this._repoConfig).then(repoRegistered => {
-      if (repoRegistered.status === 201) {
-        this._repoCreated = true;
-        page.show(this._computeRepoUrl(this._repoConfig.name));
-      }
-    });
+    if (this._defaultBranch) this._repoConfig.branches = [this._defaultBranch];
+    if (this._repoOwnerId) this._repoConfig.owners = [this._repoOwnerId];
+    return this.restApiService
+      .createRepo(this._repoConfig)
+      .then(repoRegistered => {
+        if (repoRegistered.status === 201) {
+          this._repoCreated = true;
+          page.show(this._computeRepoUrl(this._repoConfig.name));
+        }
+      });
   }
 
   _getRepoSuggestions(input: string) {
-    return this.$.restAPI.getSuggestedProjects(input).then(response => {
+    return this.restApiService.getSuggestedProjects(input).then(response => {
       const repos = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        repos.push({
-          name: key,
-          value: response[key],
-        });
+      for (const [name, project] of Object.entries(response ?? {})) {
+        repos.push({name, value: project.id});
       }
       return repos;
     });
   }
 
   _getGroupSuggestions(input: string) {
-    return this.$.restAPI.getSuggestedGroups(input).then(response => {
+    return this.restApiService.getSuggestedGroups(input).then(response => {
       const groups = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        groups.push({
-          name: key,
-          value: decodeURIComponent(response[key].id),
-        });
+      for (const [name, group] of Object.entries(response ?? {})) {
+        groups.push({name, value: decodeURIComponent(group.id)});
       }
       return groups;
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
index 02aabfe..f529ac6 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
@@ -46,6 +46,17 @@
         </iron-input>
       </section>
       <section>
+        <span class="title">Default Branch</span>
+        <iron-input autocomplete="off" bind-value="{{_defaultBranch}}">
+          <input
+            is="iron-input"
+            id="defaultBranchNameInput"
+            autocomplete="off"
+            bind-value="{{_defaultBranch}}"
+          />
+        </iron-input>
+      </section>
+      <section>
         <span class="title">Rights inherit from</span>
         <span class="value">
           <gr-autocomplete
@@ -99,5 +110,4 @@
       </section>
     </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
index 1e1fb0e..e6f9bbe 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-create-repo-dialog.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-create-repo-dialog');
 
@@ -24,9 +25,6 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-    });
     element = basicFixture.instantiate();
   });
 
@@ -41,11 +39,9 @@
       create_empty_commit: true,
       parent: 'All-Project',
       permissions_only: false,
-      owners: ['testId'],
     };
 
-    const saveStub = sinon.stub(element.$.restAPI,
-        'createRepo').callsFake(() => Promise.resolve({}));
+    const saveStub = stubRestApi('createRepo').returns(Promise.resolve({}));
 
     assert.isFalse(element.hasNewRepoName);
 
@@ -58,10 +54,10 @@
 
     element._repoOwner = 'test';
     element._repoOwnerId = 'testId';
+    element._defaultBranch = 'main';
 
     element.$.repoNameInput.bindValue = configInputObj.name;
     element.$.rightsInheritFromInput.bindValue = configInputObj.parent;
-    element.$.ownerInput.text = configInputObj.owners[0];
     element.$.initialCommit.bindValue =
         configInputObj.create_empty_commit;
     element.$.parentRepo.bindValue =
@@ -72,14 +68,15 @@
     assert.deepEqual(element._repoConfig, configInputObj);
 
     element.handleCreateRepo().then(() => {
-      assert.isTrue(saveStub.lastCall.calledWithExactly(configInputObj));
+      assert.isTrue(saveStub.lastCall.calledWithExactly(
+          {
+            ...configInputObj,
+            owners: ['testId'],
+            branches: ['main'],
+          }
+      ));
       done();
     });
   });
-
-  test('testing observer of _repoOwner', () => {
-    element._repoOwnerId = 'test-5';
-    assert.deepEqual(element._repoConfig.owners, ['test-5']);
-  });
 });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
index f7cffac..0bf292d 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -18,37 +18,26 @@
 import '../../../styles/gr-table-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-account-link/gr-account-link';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 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 {
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {
   GroupInfo,
   AccountInfo,
   EncodedGroupId,
   GroupAuditEventInfo,
 } from '../../../types/common';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
 
-export interface GrGroupAuditLog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-group-audit-log')
-export class GrGroupAuditLog extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrGroupAuditLog extends ListViewMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -62,16 +51,12 @@
   @property({type: Boolean})
   _loading = true;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Audit Log'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+  connectedCallback() {
+    super.connectedCallback();
+    fireTitleChange(this, 'Audit Log');
   }
 
   /** @override */
@@ -86,16 +71,10 @@
     }
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
-    return this.$.restAPI
+    return this.restApiService
       .getGroupAuditLog(this.groupId, errFn)
       .then(auditLog => {
         if (!auditLog) {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
index 1212685..32db13f 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
@@ -66,5 +66,4 @@
       </template>
     </tbody>
   </table>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
index 1bbfcae..fdd5d15 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
@@ -17,6 +17,7 @@
 
 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');
 
@@ -79,13 +80,11 @@
       element.groupId = 1;
 
       const response = {status: 404};
-      sinon.stub(
-          element.$.restAPI, 'getGroupAuditLog')
-          .callsFake((group, errFn) => {
-            errFn(response);
-          });
+      stubRestApi('getGroupAuditLog').callsFake((group, errFn) => {
+        errFn(response);
+      });
 
-      element.addEventListener('page-error', e => {
+      addListenerForTest(document, 'page-error', e => {
         assert.deepEqual(e.detail.response, response);
         done();
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index ae10c03..bc793fa 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -23,18 +23,11 @@
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-group-members_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {customElement, property} from '@polymer/decorators';
-import {
-  RestApiService,
-  ErrorCallback,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {
   GroupId,
@@ -43,9 +36,18 @@
   GroupInfo,
   GroupName,
 } from '../../../types/common';
-import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {
+  AutocompleteQuery,
+  AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {PolymerDomRepeatEvent} from '../../../types/types';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {
+  fireAlert,
+  firePageError,
+  fireTitleChange,
+} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const SUGGESTIONS_LIMIT = 15;
 const SAVING_ERROR_TEXT =
@@ -55,14 +57,11 @@
 
 export interface GrGroupMembers {
   $: {
-    restAPI: RestApiService & Element;
     overlay: GrOverlay;
   };
 }
 @customElement('gr-group-members')
-export class GrGroupMembers extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrGroupMembers extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -114,6 +113,8 @@
 
   _itemId?: AccountId | GroupId;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._queryMembers = input => this._getAccountSuggestions(input);
@@ -121,17 +122,11 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadGroupDetails();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Members'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Members');
   }
 
   _loadGroupDetails() {
@@ -142,50 +137,48 @@
     const promises: Promise<void>[] = [];
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
-    return this.$.restAPI.getGroupConfig(this.groupId, errFn).then(config => {
-      if (!config || !config.name) {
-        return Promise.resolve();
-      }
+    return this.restApiService
+      .getGroupConfig(this.groupId, errFn)
+      .then(config => {
+        if (!config || !config.name) {
+          return Promise.resolve();
+        }
 
-      this._groupName = config.name;
+        this._groupName = config.name;
 
-      promises.push(
-        this.$.restAPI.getIsAdmin().then(isAdmin => {
-          this._isAdmin = !!isAdmin;
-        })
-      );
+        promises.push(
+          this.restApiService.getIsAdmin().then(isAdmin => {
+            this._isAdmin = !!isAdmin;
+          })
+        );
 
-      promises.push(
-        this.$.restAPI.getIsGroupOwner(this._groupName).then(isOwner => {
-          this._groupOwner = !!isOwner;
-        })
-      );
+        promises.push(
+          this.restApiService.getIsGroupOwner(this._groupName).then(isOwner => {
+            this._groupOwner = !!isOwner;
+          })
+        );
 
-      promises.push(
-        this.$.restAPI.getGroupMembers(this._groupName).then(members => {
-          this._groupMembers = members;
-        })
-      );
+        promises.push(
+          this.restApiService.getGroupMembers(this._groupName).then(members => {
+            this._groupMembers = members;
+          })
+        );
 
-      promises.push(
-        this.$.restAPI.getIncludedGroup(this._groupName).then(includedGroup => {
-          this._includedGroups = includedGroup;
-        })
-      );
+        promises.push(
+          this.restApiService
+            .getIncludedGroup(this._groupName)
+            .then(includedGroup => {
+              this._includedGroups = includedGroup;
+            })
+        );
 
-      return Promise.all(promises).then(() => {
-        this._loading = false;
+        return Promise.all(promises).then(() => {
+          this._loading = false;
+        });
       });
-    });
   }
 
   _computeLoadingClass(loading: boolean) {
@@ -217,13 +210,13 @@
     if (!this._groupName) {
       return Promise.reject(new Error('group name undefined'));
     }
-    return this.$.restAPI
+    return this.restApiService
       .saveGroupMember(this._groupName, this._groupMemberSearchId as AccountId)
       .then(config => {
         if (!config || !this._groupName) {
           return;
         }
-        this.$.restAPI.getGroupMembers(this._groupName).then(members => {
+        this.restApiService.getGroupMembers(this._groupName).then(members => {
           this._groupMembers = members;
         });
         this._groupMemberSearchName = '';
@@ -237,24 +230,26 @@
     }
     this.$.overlay.close();
     if (this._itemType === 'member') {
-      return this.$.restAPI
+      return this.restApiService
         .deleteGroupMember(this._groupName, this._itemId! as AccountId)
         .then(itemDeleted => {
           if (itemDeleted.status === 204 && this._groupName) {
-            this.$.restAPI.getGroupMembers(this._groupName).then(members => {
-              this._groupMembers = members;
-            });
+            this.restApiService
+              .getGroupMembers(this._groupName)
+              .then(members => {
+                this._groupMembers = members;
+              });
           }
         });
     } else if (this._itemType === 'includedGroup') {
-      return this.$.restAPI
+      return this.restApiService
         .deleteIncludedGroup(this._groupName, this._itemId! as GroupId)
         .then(itemDeleted => {
           if (
             (itemDeleted.status === 204 || itemDeleted.status === 205) &&
             this._groupName
           ) {
-            this.$.restAPI
+            this.restApiService
               .getIncludedGroup(this._groupName)
               .then(includedGroup => {
                 this._includedGroups = includedGroup;
@@ -290,20 +285,14 @@
         new Error('group name or includedGroupSearchId undefined')
       );
     }
-    return this.$.restAPI
+    return this.restApiService
       .saveIncludedGroup(
         this._groupName,
         this._includedGroupSearchId.replace(/\+/g, ' ') as GroupId,
         (errResponse, err) => {
           if (errResponse) {
             if (errResponse.status === 404) {
-              this.dispatchEvent(
-                new CustomEvent('show-alert', {
-                  detail: {message: SAVING_ERROR_TEXT},
-                  bubbles: true,
-                  composed: true,
-                })
-              );
+              fireAlert(this, SAVING_ERROR_TEXT);
               return errResponse;
             }
             throw Error(errResponse.statusText);
@@ -315,9 +304,11 @@
         if (!config || !this._groupName) {
           return;
         }
-        this.$.restAPI.getIncludedGroup(this._groupName).then(includedGroup => {
-          this._includedGroups = includedGroup;
-        });
+        this.restApiService
+          .getIncludedGroup(this._groupName)
+          .then(includedGroup => {
+            this._includedGroups = includedGroup;
+          });
         this._includedGroupSearchName = '';
         this._includedGroupSearchId = '';
       });
@@ -343,26 +334,21 @@
     if (input.length === 0) {
       return Promise.resolve([]);
     }
-    return this.$.restAPI
+    return this.restApiService
       .getSuggestedAccounts(input, SUGGESTIONS_LIMIT)
       .then(accounts => {
+        if (!accounts) return [];
         const accountSuggestions = [];
-        let nameAndEmail;
-        if (!accounts) {
-          return [];
-        }
-        for (const key in accounts) {
-          if (!hasOwnProperty(accounts, key)) {
-            continue;
-          }
-          if (accounts[key].email !== undefined) {
-            nameAndEmail = `${accounts[key].name} <${accounts[key].email}>`;
+        for (const account of accounts) {
+          let nameAndEmail;
+          if (account.email !== undefined) {
+            nameAndEmail = `${account.name} <${account.email}>`;
           } else {
-            nameAndEmail = accounts[key].name;
+            nameAndEmail = account.name;
           }
           accountSuggestions.push({
             name: nameAndEmail,
-            value: accounts[key]._account_id,
+            value: account._account_id?.toString(),
           });
         }
         return accountSuggestions;
@@ -370,16 +356,10 @@
   }
 
   _getGroupSuggestions(input: string) {
-    return this.$.restAPI.getSuggestedGroups(input).then(response => {
-      const groups = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        groups.push({
-          name: key,
-          value: decodeURIComponent(response[key].id),
-        });
+    return this.restApiService.getSuggestedGroups(input).then(response => {
+      const groups: AutocompleteSuggestion[] = [];
+      for (const [name, group] of Object.entries(response ?? {})) {
+        groups.push({name, value: decodeURIComponent(group.id)});
       }
       return groups;
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
index 2d3f8fc..47da237 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
@@ -56,8 +56,8 @@
       display: none;
     }
   </style>
-  <main
-    class$="gr-form-styles [[_computeHideItemClass(_groupOwner, _isAdmin)]]"
+  <div
+    class$="main gr-form-styles [[_computeHideItemClass(_groupOwner, _isAdmin)]]"
   >
     <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
       Loading...
@@ -136,9 +136,7 @@
               <tr class="headerRow">
                 <th class="groupNameHeader">Group Name</th>
                 <th class="descriptionHeader">Description</th>
-                <th class="deleteIncludedHeader">
-                  Delete Group
-                </th>
+                <th class="deleteIncludedHeader">Delete Group</th>
               </tr>
             </tbody>
             <tbody>
@@ -170,7 +168,7 @@
         </fieldset>
       </div>
     </div>
-  </main>
+  </div>
   <gr-overlay id="overlay" with-backdrop="">
     <gr-confirm-delete-item-dialog
       class="confirmDialog"
@@ -180,5 +178,4 @@
       item-type="[[_itemType]]"
     ></gr-confirm-delete-item-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
index b5d2217..672ac07 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
@@ -18,7 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-group-members.js';
 import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
+import {addListenerForTest, stubBaseUrl, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-group-members');
 
@@ -78,70 +78,52 @@
     },
     ];
 
-    stub('gr-rest-api-interface', {
-      getSuggestedAccounts(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve([
-            {
-              _account_id: 1000096,
-              name: 'test-account',
-              email: 'test.account@example.com',
-              username: 'test123',
-            },
-            {
-              _account_id: 1001439,
-              name: 'test-admin',
-              email: 'test.admin@example.com',
-              username: 'test_admin',
-            },
-            {
-              _account_id: 1001439,
-              name: 'test-git',
-              username: 'test_git',
-            },
-          ]);
-        } else {
-          return Promise.resolve({});
-        }
-      },
-      getSuggestedGroups(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve({
-            'test-admin': {
-              id: '1ce023d3fb4e4260776fb92cd08b52bbd21ce70a',
-            },
-            'test/Administrator (admin)': {
-              id: 'test%3Aadmin',
-            },
-          });
-        } else {
-          return Promise.resolve({});
-        }
-      },
-      getLoggedIn() { return Promise.resolve(true); },
-      getConfig() {
-        return Promise.resolve();
-      },
-      getGroupMembers() {
-        return Promise.resolve(groupMembers);
-      },
-      getIsGroupOwner() {
-        return Promise.resolve(true);
-      },
-      getIncludedGroup() {
-        return Promise.resolve(includedGroups);
-      },
-      getAccountCapabilities() {
-        return Promise.resolve();
-      },
+    stubRestApi('getSuggestedAccounts').callsFake(input => {
+      if (input.startsWith('test')) {
+        return Promise.resolve([
+          {
+            _account_id: 1000096,
+            name: 'test-account',
+            email: 'test.account@example.com',
+            username: 'test123',
+          },
+          {
+            _account_id: 1001439,
+            name: 'test-admin',
+            email: 'test.admin@example.com',
+            username: 'test_admin',
+          },
+          {
+            _account_id: 1001439,
+            name: 'test-git',
+            username: 'test_git',
+          },
+        ]);
+      } else {
+        return Promise.resolve([]);
+      }
     });
+    stubRestApi('getSuggestedGroups').callsFake(input => {
+      if (input.startsWith('test')) {
+        return Promise.resolve({
+          'test-admin': {
+            id: '1ce023d3fb4e4260776fb92cd08b52bbd21ce70a',
+          },
+          'test/Administrator (admin)': {
+            id: 'test%3Aadmin',
+          },
+        });
+      } else {
+        return Promise.resolve({});
+      }
+    });
+    stubRestApi('getGroupMembers').returns(Promise.resolve(groupMembers));
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+    stubRestApi('getIncludedGroup').returns(Promise.resolve(includedGroups));
     element = basicFixture.instantiate();
     stubBaseUrl('https://test/site');
     element.groupId = 1;
-    groupStub = sinon.stub(
-        element.$.restAPI,
-        'getGroupConfig')
-        .callsFake(() => Promise.resolve(groups));
+    groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(groups));
     return element._loadGroupDetails();
   });
 
@@ -162,7 +144,7 @@
 
     const memberName = 'test-admin';
 
-    const saveStub = sinon.stub(element.$.restAPI, 'saveGroupMember')
+    const saveStub = stubRestApi('saveGroupMember')
         .callsFake(() => Promise.resolve({}));
 
     const button = element.$.saveGroupMember;
@@ -187,8 +169,7 @@
 
     const includedGroupName = 'testName';
 
-    const saveIncludedGroupStub = sinon.stub(
-        element.$.restAPI, 'saveIncludedGroup')
+    const saveIncludedGroupStub = stubRestApi('saveIncludedGroup')
         .callsFake(() => Promise.resolve({}));
 
     const button = element.$.saveIncludedGroups;
@@ -219,8 +200,14 @@
       status: 404,
       ok: false,
     };
-    sinon.stub(element.$.restAPI._restApiHelper, 'fetch').callsFake(
-        () => Promise.resolve(errorResponse));
+    stubRestApi('saveIncludedGroup').callsFake((
+        groupName,
+        includedGroup,
+        errFn
+    ) => {
+      errFn(errorResponse);
+      return Promise.resolve(undefined);
+    });
 
     element.$.groupMemberSearchInput.text = memberName;
     element.$.groupMemberSearchInput.value = 1234;
@@ -232,13 +219,8 @@
 
   test('add included group network-error throws an exception', async () => {
     element._groupOwner = true;
-
     const memberName = 'bad-name';
-    const alertStub = sinon.stub();
-    element.addEventListener('show-alert', alertStub);
-    const err = new Error();
-    sinon.stub(element.$.restAPI._restApiHelper, 'fetch').callsFake(
-        () => Promise.reject(err));
+    stubRestApi('saveIncludedGroup').throws(new Error());
 
     element.$.groupMemberSearchInput.text = memberName;
     element.$.groupMemberSearchInput.value = 1234;
@@ -366,12 +348,11 @@
     element.groupId = 1;
 
     const response = {status: 404};
-    sinon.stub(
-        element.$.restAPI, 'getGroupConfig')
+    stubRestApi('getGroupConfig')
         .callsFake((group, errFn) => {
           errFn(response);
         });
-    element.addEventListener('page-error', e => {
+    addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
       done();
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 511bf5c..0bb13ba 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -21,10 +21,7 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-group_html';
 import {customElement, property, observe} from '@polymer/decorators';
@@ -34,10 +31,12 @@
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {hasOwnProperty} from '../../../utils/common-util';
+  fireEvent,
+  firePageError,
+  fireTitleChange,
+} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -54,7 +53,6 @@
 
 export interface GrGroup {
   $: {
-    restAPI: RestApiService & Element;
     loading: HTMLDivElement;
   };
 }
@@ -71,9 +69,7 @@
 }
 
 @customElement('gr-group')
-export class GrGroup extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrGroup extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -126,14 +122,16 @@
   @property({type: Boolean})
   _isAdmin = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = (input: string) => this._getGroupSuggestions(input);
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadGroup();
   }
 
@@ -145,56 +143,46 @@
     const promises: Promise<unknown>[] = [];
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
-    return this.$.restAPI.getGroupConfig(this.groupId, errFn).then(config => {
-      if (!config || !config.name) {
-        return Promise.resolve();
-      }
+    return this.restApiService
+      .getGroupConfig(this.groupId, errFn)
+      .then(config => {
+        if (!config || !config.name) {
+          return Promise.resolve();
+        }
 
-      this._groupName = config.name;
-      this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
+        this._groupName = config.name;
+        this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
 
-      promises.push(
-        this.$.restAPI.getIsAdmin().then(isAdmin => {
-          this._isAdmin = !!isAdmin;
-        })
-      );
+        promises.push(
+          this.restApiService.getIsAdmin().then(isAdmin => {
+            this._isAdmin = !!isAdmin;
+          })
+        );
 
-      promises.push(
-        this.$.restAPI.getIsGroupOwner(config.name).then(isOwner => {
-          this._groupOwner = !!isOwner;
-        })
-      );
+        promises.push(
+          this.restApiService.getIsGroupOwner(config.name).then(isOwner => {
+            this._groupOwner = !!isOwner;
+          })
+        );
 
-      // If visible to all is undefined, set to false. If it is defined
-      // as false, setting to false is fine. If any optional values
-      // are added with a default of true, then this would need to be an
-      // undefined check and not a truthy/falsy check.
-      if (config.options && !config.options.visible_to_all) {
-        config.options.visible_to_all = false;
-      }
-      this._groupConfig = config;
+        // If visible to all is undefined, set to false. If it is defined
+        // as false, setting to false is fine. If any optional values
+        // are added with a default of true, then this would need to be an
+        // undefined check and not a truthy/falsy check.
+        if (config.options && !config.options.visible_to_all) {
+          config.options.visible_to_all = false;
+        }
+        this._groupConfig = config;
 
-      this.dispatchEvent(
-        new CustomEvent('title-change', {
-          detail: {title: config.name},
-          composed: true,
-          bubbles: true,
-        })
-      );
+        fireTitleChange(this, config.name);
 
-      return Promise.all(promises).then(() => {
-        this._loading = false;
+        return Promise.all(promises).then(() => {
+          this._loading = false;
+        });
       });
-    });
   }
 
   _computeLoadingClass(loading: boolean) {
@@ -211,7 +199,7 @@
       return Promise.reject(new Error('invalid groupId or config name'));
     }
     const groupName = groupConfig.name;
-    return this.$.restAPI
+    return this.restApiService
       .saveGroupName(this.groupId, groupName)
       .then(config => {
         if (config.status === 200) {
@@ -220,6 +208,7 @@
             name: groupName,
             external: !this._groupIsInternal,
           };
+          fireEvent(this, 'name-changed');
           this.dispatchEvent(
             new CustomEvent('name-changed', {
               detail,
@@ -239,7 +228,7 @@
       owner = decodeURIComponent(this._groupConfigOwner);
     }
     if (!owner) return;
-    return this.$.restAPI.saveGroupOwner(this.groupId, owner).then(() => {
+    return this.restApiService.saveGroupOwner(this.groupId, owner).then(() => {
       this._owner = false;
     });
   }
@@ -247,7 +236,7 @@
   _handleSaveDescription() {
     if (!this.groupId || !this._groupConfig || !this._groupConfig.description)
       return;
-    return this.$.restAPI
+    return this.restApiService
       .saveGroupDescription(this.groupId, this._groupConfig.description)
       .then(() => {
         this._description = false;
@@ -261,9 +250,11 @@
 
     const options = {visible_to_all: visible};
 
-    return this.$.restAPI.saveGroupOptions(this.groupId, options).then(() => {
-      this._options = false;
-    });
+    return this.restApiService
+      .saveGroupOptions(this.groupId, options)
+      .then(() => {
+        this._options = false;
+      });
   }
 
   @observe('_groupConfig.name')
@@ -303,16 +294,10 @@
   }
 
   _getGroupSuggestions(input: string) {
-    return this.$.restAPI.getSuggestedGroups(input).then(response => {
+    return this.restApiService.getSuggestedGroups(input).then(response => {
       const groups: AutocompleteSuggestion[] = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        groups.push({
-          name: key,
-          value: decodeURIComponent(response[key].id),
-        });
+      for (const [name, group] of Object.entries(response ?? {})) {
+        groups.push({name, value: decodeURIComponent(group.id)});
       }
       return groups;
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
index aed73bf..ba089f6 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
@@ -32,7 +32,7 @@
   <style include="gr-form-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
-  <main class="gr-form-styles read-only">
+  <div class="main gr-form-styles read-only">
     <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
       Loading...
     </div>
@@ -164,6 +164,5 @@
         </fieldset>
       </div>
     </div>
-  </main>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </div>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
index 34f6b6a..0668330 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-group.js';
+import {stubRestApi, addListenerForTest} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-group');
 
@@ -36,14 +37,8 @@
   };
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-    });
     element = basicFixture.instantiate();
-    groupStub = sinon.stub(
-        element.$.restAPI,
-        'getGroupConfig')
-        .callsFake(() => Promise.resolve(group));
+    groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(group));
   });
 
   test('loading displays before group config is loaded', () => {
@@ -55,10 +50,7 @@
   });
 
   test('default values are populated with internal group', done => {
-    sinon.stub(
-        element.$.restAPI,
-        'getIsGroupOwner')
-        .callsFake(() => Promise.resolve(true));
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
     element.groupId = 1;
     element._loadGroup().then(() => {
       assert.isTrue(element._groupIsInternal);
@@ -71,14 +63,9 @@
     const groupExternal = {...group};
     groupExternal.id = 'external-group-id';
     groupStub.restore();
-    groupStub = sinon.stub(
-        element.$.restAPI,
-        'getGroupConfig')
-        .callsFake(() => Promise.resolve(groupExternal));
-    sinon.stub(
-        element.$.restAPI,
-        'getIsGroupOwner')
-        .callsFake(() => Promise.resolve(true));
+    groupStub = stubRestApi('getGroupConfig').returns(
+        Promise.resolve(groupExternal));
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
     element.groupId = 1;
     element._loadGroup().then(() => {
       assert.isFalse(element._groupIsInternal);
@@ -96,15 +83,8 @@
     };
     element._groupName = groupName;
 
-    sinon.stub(
-        element.$.restAPI,
-        'getIsGroupOwner')
-        .callsFake(() => Promise.resolve(true));
-
-    sinon.stub(
-        element.$.restAPI,
-        'saveGroupName')
-        .callsFake(() => Promise.resolve({status: 200}));
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+    stubRestApi('saveGroupName').returns(Promise.resolve({status: 200}));
 
     const button = element.$.inputUpdateNameBtn;
 
@@ -135,10 +115,7 @@
     element._groupConfigOwner = 'testId';
     element._groupOwner = true;
 
-    sinon.stub(
-        element.$.restAPI,
-        'getIsGroupOwner')
-        .callsFake(() => Promise.resolve({status: 200}));
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve({status: 200}));
 
     const button = element.$.inputUpdateOwnerBtn;
 
@@ -162,10 +139,7 @@
   test('test for undefined group name', done => {
     groupStub.restore();
 
-    sinon.stub(
-        element.$.restAPI,
-        'getGroupConfig')
-        .callsFake(() => Promise.resolve({}));
+    stubRestApi('getGroupConfig').returns(Promise.resolve({}));
 
     assert.isUndefined(element.groupId);
 
@@ -189,8 +163,7 @@
       name: 'test-group',
     };
     element.groupId = 'gg';
-    sinon.stub(element.$.restAPI, 'saveGroupName')
-        .returns(Promise.resolve({status: 200}));
+    stubRestApi('saveGroupName').returns(Promise.resolve({status: 200}));
 
     const showStub = sinon.stub(element, 'dispatchEvent');
     element._handleSaveName()
@@ -239,12 +212,11 @@
     element.groupId = 1;
 
     const response = {status: 404};
-    sinon.stub(
-        element.$.restAPI, 'getGroupConfig').callsFake((group, errFn) => {
+    stubRestApi('getGroupConfig').callsFake((group, errFn) => {
       errFn(response);
     });
 
-    element.addEventListener('page-error', e => {
+    addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
       done();
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index 9caebde..4b17476 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -21,11 +21,8 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-rule-editor/gr-rule-editor';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-permission_html';
 import {
@@ -34,8 +31,6 @@
   PermissionArray,
 } from '../../../utils/access-util';
 import {customElement, property, observe} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {
   LabelNameToLabelTypeInfoMap,
   LabelTypeInfoValues,
@@ -57,6 +52,8 @@
   EditableProjectAccessGroups,
 } from '../gr-repo-access/gr-repo-access-interfaces';
 import {PolymerDomRepeatEvent} from '../../../types/types';
+import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
 
 const MAX_AUTOCOMPLETE_RESULTS = 20;
 
@@ -66,7 +63,6 @@
 
 export interface GrPermission {
   $: {
-    restAPI: RestApiService & Element;
     groupAutocomplete: GrAutocomplete;
   };
 }
@@ -97,9 +93,7 @@
  * @event added-permission-removed
  */
 @customElement('gr-permission')
-export class GrPermission extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrPermission extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -146,14 +140,11 @@
   @property({type: Boolean})
   _originalExclusiveValue?: boolean;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = () => this._getGroupSuggestions();
-  }
-
-  /** @override */
-  created() {
-    super.created();
     this.addEventListener('access-saved', () => this._handleAccessSaved());
   }
 
@@ -226,9 +217,7 @@
     }
     this.permission.value.modified = true;
     // Allows overall access page to know a change has been made.
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _handleRemovePermission() {
@@ -236,18 +225,11 @@
       return;
     }
     if (this.permission.value.added) {
-      this.dispatchEvent(
-        new CustomEvent('added-permission-removed', {
-          bubbles: true,
-          composed: true,
-        })
-      );
+      fireEvent(this, 'added-permission-removed');
     }
     this._deleted = true;
     this.permission.value.deleted = true;
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   @observe('_rules.splices')
@@ -341,7 +323,7 @@
   }
 
   _getGroupSuggestions(): Promise<AutocompleteSuggestion[]> {
-    return this.$.restAPI
+    return this.restApiService
       .getSuggestedGroups(
         this._groupFilter || '',
         this.repo,
@@ -349,14 +331,8 @@
       )
       .then(response => {
         const groups: GroupSuggestion[] = [];
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
-          }
-          groups.push({
-            name: key,
-            value: response[key],
-          });
+        for (const [name, value] of Object.entries(response ?? {})) {
+          groups.push({name, value});
         }
         // Does not return groups in which we already have rules for.
         return groups
@@ -415,9 +391,7 @@
     value.added = true;
     // See comment above for why we cannot use "this.set(...)" here.
     this.permission.value.rules[groupId] = value;
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _computeHasRange(name: string) {
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
index 9795c92..3559194 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
@@ -93,7 +93,7 @@
               checked="{{permission.value.exclusive}}"
               on-change="_handleValueChange"
               disabled$="[[!editing]]"
-              on-tap="_onTapExclusiveToggle"
+              on-click="_onTapExclusiveToggle"
             ></paper-toggle-button
             >Exclusive
           </template>
@@ -140,5 +140,4 @@
     </div>
     <!-- end deletedContainer -->
   </section>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
index 32430ec..d5668d8 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-permission.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-permission');
 
@@ -25,7 +26,7 @@
 
   setup(() => {
     element = basicFixture.instantiate();
-    sinon.stub(element.$.restAPI, 'getSuggestedGroups').returns(
+    stubRestApi('getSuggestedGroups').returns(
         Promise.resolve({
           'Administrators': {
             id: '4c97682e6ce61b7247f3381b6f1789356666de7f',
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-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 ac82547..0a0259a 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
@@ -20,8 +20,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-plugin-config-array-editor_html';
 import {property, customElement} from '@polymer/decorators';
@@ -37,9 +35,7 @@
 }
 
 @customElement('gr-plugin-config-array-editor')
-class GrPluginConfigArrayEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+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 5039972..96cae0e 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -17,9 +17,6 @@
 import '../../../styles/gr-table-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-list-view/gr-list-view';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-plugin-list_html';
 import {
@@ -27,22 +24,16 @@
   ListViewParams,
 } from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {ErrorCallback} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {PluginInfo} from '../../../types/common';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 interface PluginInfoWithName extends PluginInfo {
   name: string;
 }
-export interface GrPluginList {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-plugin-list')
-export class GrPluginList extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrPluginList extends ListViewMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -81,16 +72,12 @@
   @property({type: String})
   _filter = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Plugins'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+  connectedCallback() {
+    super.connectedCallback();
+    fireTitleChange(this, 'Plugins');
   }
 
   _paramsChanged(params: ListViewParams) {
@@ -103,15 +90,9 @@
 
   _getPlugins(filter: string, pluginsPerPage: number, offset?: number) {
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
-    return this.$.restAPI
+    return this.restApiService
       .getPlugins(filter, pluginsPerPage, offset, errFn)
       .then(plugins => {
         if (!plugins) {
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts
index d5318b5..eeca478 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts
@@ -78,5 +78,4 @@
       </tbody>
     </table>
   </gr-list-view>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
index 7303748..a9281e4 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-plugin-list.js';
 import 'lodash/lodash.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-plugin-list');
 
@@ -54,13 +55,7 @@
   suite('list with plugins', () => {
     setup(done => {
       plugins = _.times(26, pluginGenerator);
-
-      stub('gr-rest-api-interface', {
-        getPlugins(num, offset) {
-          return Promise.resolve(plugins);
-        },
-      });
-
+      stubRestApi('getPlugins').returns(Promise.resolve(plugins));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -113,13 +108,7 @@
   suite('list with less then 26 plugins', () => {
     setup(done => {
       plugins = _.times(25, pluginGenerator);
-
-      stub('gr-rest-api-interface', {
-        getPlugins(num, offset) {
-          return Promise.resolve(plugins);
-        },
-      });
-
+      stubRestApi('getPlugins').returns(Promise.resolve(plugins));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -129,24 +118,17 @@
   });
 
   suite('filter', () => {
-    test('_paramsChanged', done => {
-      sinon.stub(
-          element.$.restAPI,
-          'getPlugins')
-          .callsFake(() => Promise.resolve(plugins));
+    test('_paramsChanged', async () => {
+      const getPluginsStub = stubRestApi('getPlugins');
+      getPluginsStub.returns(Promise.resolve(plugins));
       const value = {
         filter: 'test',
         offset: 25,
       };
-      element._paramsChanged(value).then(() => {
-        assert.equal(element.$.restAPI.getPlugins.lastCall.args[0],
-            'test');
-        assert.equal(element.$.restAPI.getPlugins.lastCall.args[1],
-            25);
-        assert.equal(element.$.restAPI.getPlugins.lastCall.args[2],
-            25);
-        done();
-      });
+      await element._paramsChanged(value);
+      assert.equal(getPluginsStub.lastCall.args[0], 'test');
+      assert.equal(getPluginsStub.lastCall.args[1], 25);
+      assert.equal(getPluginsStub.lastCall.args[2], 25);
     });
   });
 
@@ -168,12 +150,12 @@
   suite('404', () => {
     test('fires page-error', done => {
       const response = {status: 404};
-      sinon.stub(element.$.restAPI, 'getPlugins').callsFake(
+      stubRestApi('getPlugins').callsFake(
           (filter, pluginsPerPage, opt_offset, errFn) => {
             errFn(response);
           });
 
-      element.addEventListener('page-error', e => {
+      addListenerForTest(document, 'page-error', e => {
         assert.deepEqual(e.detail.response, response);
         done();
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
index 40a1e0a..869a416 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
@@ -72,9 +72,7 @@
   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 b96ec2c..80c58ca 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -17,11 +17,8 @@
 import '../../../styles/gr-menu-page-styles';
 import '../../../styles/gr-subpage-styles';
 import '../../../styles/shared-styles';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-access-section/gr-access-section';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-access_html';
 import {encodeURL, getBaseUrl, singleDecodeURL} from '../../../utils/url-util';
@@ -38,8 +35,6 @@
   UrlEncodedRepoName,
   ProjectAccessGroups,
 } from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrAccessSection} from '../gr-access-section/gr-access-section';
 import {
@@ -52,26 +47,21 @@
   PropertyTreeNode,
   PrimitiveValue,
 } from './gr-repo-access-interfaces';
+import {firePageError, fireAlert} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {WebLinkInfo} from '../../../types/diff';
 
 const NOTHING_TO_SAVE = 'No changes to save.';
 
 const MAX_AUTOCOMPLETE_RESULTS = 50;
 
-export interface GrRepoAccess {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 /**
  * Fired when save is a no-op
  *
  * @event show-alert
  */
 @customElement('gr-repo-access')
-export class GrRepoAccess extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRepoAccess extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -119,21 +109,18 @@
   _sections?: PermissionAccessSection[];
 
   @property({type: Array})
-  _weblinks?: string[];
+  _weblinks?: WebLinkInfo[];
 
   @property({type: Boolean})
   _loading = true;
 
-  private _originalInheritsFrom?: ProjectInfo | null;
+  private originalInheritsFrom?: ProjectInfo | null;
+
+  private readonly restApiService = appContext.restApiService;
 
   constructor() {
     super();
     this._query = () => this._getInheritFromSuggestions();
-  }
-
-  /** @override */
-  created() {
-    super.created();
     this.addEventListener('access-modified', () =>
       this._handleAccessModified()
     );
@@ -155,20 +142,14 @@
 
   _reload(repo: RepoName) {
     const errFn = (response?: Response | null) => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
     this._editing = false;
 
     // Always reset sections when a project changes.
     this._sections = [];
-    const sectionsPromises = this.$.restAPI
+    const sectionsPromises = this.restApiService
       .getRepoAccessRights(repo, errFn)
       .then(res => {
         if (!res) {
@@ -183,7 +164,7 @@
               ...res.inherits_from,
             }
           : null;
-        this._originalInheritsFrom = res.inherits_from
+        this.originalInheritsFrom = res.inherits_from
           ? {
               ...res.inherits_from,
             }
@@ -204,7 +185,7 @@
         return toSortedPermissionsArray(this._local);
       });
 
-    const capabilitiesPromises = this.$.restAPI
+    const capabilitiesPromises = this.restApiService
       .getCapabilities(errFn)
       .then(res => {
         if (!res) {
@@ -214,13 +195,15 @@
         return res;
       });
 
-    const labelsPromises = this.$.restAPI.getRepo(repo, errFn).then(res => {
-      if (!res) {
-        return Promise.resolve(undefined);
-      }
+    const labelsPromises = this.restApiService
+      .getRepo(repo, errFn)
+      .then(res => {
+        if (!res) {
+          return Promise.resolve(undefined);
+        }
 
-      return res.labels;
-    });
+        return res.labels;
+      });
 
     return Promise.all([
       sectionsPromises,
@@ -252,7 +235,7 @@
   }
 
   _getInheritFromSuggestions(): Promise<AutocompleteSuggestion[]> {
-    return this.$.restAPI
+    return this.restApiService
       .getRepos(this._inheritFromFilter, MAX_AUTOCOMPLETE_RESULTS)
       .then(response => {
         const projects: AutocompleteSuggestion[] = [];
@@ -314,9 +297,18 @@
     }
     // Restore inheritFrom.
     if (this._inheritsFrom) {
-      this._inheritsFrom = {...this._originalInheritsFrom};
-      this._inheritFromFilter =
-        'name' in this._inheritsFrom ? this._inheritsFrom.name : undefined;
+      // 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.originalInheritsFrom}
+        : {};
+      this._inheritsFrom = copy;
+      this._inheritFromFilter = 'name' in copy ? copy.name : undefined;
     }
     if (!this._local) {
       return;
@@ -377,11 +369,9 @@
   /**
    * Used to recursively remove any objects with a 'deleted' bit.
    */
-  _recursivelyRemoveDeleted(obj: PropertyTreeNode) {
-    for (const k in obj) {
-      if (!hasOwnProperty(obj, k)) {
-        continue;
-      }
+  _recursivelyRemoveDeleted(obj?: PropertyTreeNode) {
+    if (!obj) return;
+    for (const k of Object.keys(obj)) {
       const node = obj[k];
       if (typeof node === 'object') {
         if (node.deleted) {
@@ -394,17 +384,15 @@
   }
 
   _recursivelyUpdateAddRemoveObj(
-    obj: PropertyTreeNode,
+    obj: PropertyTreeNode | undefined,
     addRemoveObj: {
       add: PropertyTreeNode;
       remove: PropertyTreeNode;
     },
     path: string[] = []
   ) {
-    for (const k in obj) {
-      if (!hasOwnProperty(obj, k)) {
-        continue;
-      }
+    if (!obj) return;
+    for (const k of Object.keys(obj)) {
       const node = obj[k];
       if (typeof node === 'object') {
         const updatedId = node.updatedId;
@@ -458,8 +446,8 @@
       remove: {},
     };
 
-    const originalInheritsFromId = this._originalInheritsFrom
-      ? singleDecodeURL(this._originalInheritsFrom.id)
+    const originalInheritsFromId = this.originalInheritsFrom
+      ? singleDecodeURL(this.originalInheritsFrom.id)
       : null;
     // TODO(TS): this._inheritsFrom as ProjectInfo might be a mistake.
     // _inheritsFrom can be {}
@@ -516,13 +504,7 @@
       !Object.keys(addRemoveObj.remove).length &&
       !addRemoveObj.parent
     ) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: NOTHING_TO_SAVE},
-          bubbles: true,
-          composed: true,
-        })
-      );
+      fireAlert(this, NOTHING_TO_SAVE);
       return;
     }
     const obj: ProjectAccessInput = ({
@@ -548,7 +530,7 @@
     if (!repo) {
       return Promise.resolve();
     }
-    return this.$.restAPI
+    return this.restApiService
       .setRepoAccessRights(repo, obj)
       .then(() => {
         this._reload(repo);
@@ -573,7 +555,7 @@
     if (!this.repo) {
       return;
     }
-    return this.$.restAPI
+    return this.restApiService
       .setRepoAccessRightsForReview(this.repo, obj)
       .then(change => {
         GerritNav.navigateToChange(change);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
index 2c21329..1eba2e7 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
@@ -60,7 +60,7 @@
   <style include="gr-menu-page-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
-  <main class$="[[_computeMainClass(_ownerOf, _canUpload, _editing)]]">
+  <div class$="main [[_computeMainClass(_ownerOf, _canUpload, _editing)]]">
     <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
       Loading...
     </div>
@@ -143,6 +143,5 @@
         >
       </div>
     </div>
-  </main>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </div>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
index d3204e1..a4e019e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
@@ -20,6 +20,7 @@
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {toSortedPermissionsArray} from '../../../utils/access-util.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-access');
 
@@ -101,11 +102,8 @@
   };
   setup(() => {
     element = basicFixture.instantiate();
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve(null); },
-    });
-    repoStub = sinon.stub(element.$.restAPI, 'getRepo').returns(
-        Promise.resolve(repoRes));
+    stubRestApi('getAccount').returns(Promise.resolve(null));
+    repoStub = stubRestApi('getRepo').returns(Promise.resolve(repoRes));
     element._loading = false;
     element._ownerOf = [];
     element._canUpload = false;
@@ -118,14 +116,14 @@
   });
 
   test('_repoChanged', done => {
-    const accessStub = sinon.stub(element.$.restAPI,
+    const accessStub = stubRestApi(
         'getRepoAccessRights');
 
     accessStub.withArgs('New Repo').returns(
         Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
     accessStub.withArgs('Another New Repo')
         .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-    const capabilitiesStub = sinon.stub(element.$.restAPI,
+    const capabilitiesStub = stubRestApi(
         'getCapabilities');
     capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
 
@@ -160,9 +158,9 @@
         name: 'Access Database',
       },
     };
-    const accessStub = sinon.stub(element.$.restAPI, 'getRepoAccessRights')
+    const accessStub = stubRestApi('getRepoAccessRights')
         .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-    const capabilitiesStub = sinon.stub(element.$.restAPI,
+    const capabilitiesStub = stubRestApi(
         'getCapabilities').returns(Promise.resolve(capabilitiesRes));
 
     element._repoChanged().then(() => {
@@ -240,13 +238,11 @@
   test('fires page-error', done => {
     const response = {status: 404};
 
-    sinon.stub(
-        element.$.restAPI, 'getRepoAccessRights')
-        .callsFake((repoName, errFn) => {
-          errFn(response);
-        });
+    stubRestApi('getRepoAccessRights').callsFake((repoName, errFn) => {
+      errFn(response);
+    });
 
-    element.addEventListener('page-error', e => {
+    addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
       done();
     });
@@ -378,7 +374,7 @@
 
     test('_handleSaveForReview', () => {
       const saveStub =
-          sinon.stub(element.$.restAPI, 'setRepoAccessRightsForReview');
+          stubRestApi('setRepoAccessRightsForReview');
       sinon.stub(element, '_computeAddAndRemove').returns({
         add: {},
         remove: {},
@@ -1161,11 +1157,11 @@
           },
         },
       };
-      sinon.stub(element.$.restAPI, 'getRepoAccessRights').returns(
+      stubRestApi('getRepoAccessRights').returns(
           Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
       sinon.stub(GerritNav, 'navigateToChange');
       let resolver;
-      const saveStub = sinon.stub(element.$.restAPI,
+      const saveStub = stubRestApi(
           'setRepoAccessRights')
           .returns(new Promise(r => resolver = r));
 
@@ -1208,11 +1204,11 @@
           },
         },
       };
-      sinon.stub(element.$.restAPI, 'getRepoAccessRights').returns(
+      stubRestApi('getRepoAccessRights').returns(
           Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
       sinon.stub(GerritNav, 'navigateToChange');
       let resolver;
-      const saveForReviewStub = sinon.stub(element.$.restAPI,
+      const saveForReviewStub = stubRestApi(
           'setRepoAccessRightsForReview')
           .returns(new Promise(r => resolver = r));
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
index a74f4bb..df78df3 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -22,19 +22,12 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-create-change-dialog/gr-create-change-dialog';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-commands_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
 import {
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {
   BranchName,
   ConfigInfo,
   PatchSetNum,
@@ -42,6 +35,13 @@
 } from '../../../types/common';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrCreateChangeDialog} from '../gr-create-change-dialog/gr-create-change-dialog';
+import {
+  fireAlert,
+  firePageError,
+  fireTitleChange,
+} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const GC_MESSAGE = 'Garbage collection completed successfully.';
 const CONFIG_BRANCH = 'refs/meta/config' as BranchName;
@@ -53,16 +53,13 @@
 
 export interface GrRepoCommands {
   $: {
-    restAPI: RestApiService & Element;
     createChangeOverlay: GrOverlay;
     createNewChangeModal: GrCreateChangeDialog;
   };
 }
 
 @customElement('gr-repo-commands')
-export class GrRepoCommands extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRepoCommands extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -90,18 +87,14 @@
   @property({type: Boolean})
   _runningGC = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadRepo();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Repo Commands'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Repo Commands');
   }
 
   _loadRepo() {
@@ -109,16 +102,10 @@
       // Do not process the error, if the component is not attached to the DOM
       // anymore, which at least in tests can happen.
       if (!this.isConnected) return;
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
-    this.$.restAPI.getProjectConfig(this.repo, errFn).then(config => {
+    this.restApiService.getProjectConfig(this.repo, errFn).then(config => {
       if (!config) return;
       // Do not process the response, if the component is not attached to the
       // DOM anymore, which at least in tests can happen.
@@ -137,18 +124,13 @@
   }
 
   _handleRunningGC() {
+    if (!this.repo) return;
     this._runningGC = true;
-    return this.$.restAPI
+    return this.restApiService
       .runRepoGC(this.repo)
       .then(response => {
         if (response?.status === 200) {
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {message: GC_MESSAGE},
-              bubbles: true,
-              composed: true,
-            })
-          );
+          fireAlert(this, GC_MESSAGE);
         }
       })
       .finally(() => {
@@ -177,7 +159,7 @@
    */
   _handleEditRepoConfig() {
     this._editingConfig = true;
-    return this.$.restAPI
+    return this.restApiService
       .createChange(
         this.repo,
         CONFIG_BRANCH,
@@ -190,13 +172,7 @@
         const message = change
           ? CREATE_CHANGE_SUCCEEDED_MESSAGE
           : CREATE_CHANGE_FAILED_MESSAGE;
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(this, message);
         if (!change) {
           return;
         }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts
index 3880e4a..f572ac3 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts
@@ -28,7 +28,7 @@
       margin-bottom: var(--spacing-xxl);
     }
   </style>
-  <main class="gr-form-styles read-only">
+  <div class="main gr-form-styles read-only">
     <h1 id="Title" class="heading-1">Repository Commands</h1>
     <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
       Loading...
@@ -67,7 +67,7 @@
         </gr-endpoint-decorator>
       </div>
     </div>
-  </main>
+  </div>
   <gr-overlay id="createChangeOverlay" with-backdrop="">
     <gr-dialog
       id="createChangeDialog"
@@ -76,9 +76,7 @@
       on-confirm="_handleCreateChange"
       on-cancel="_handleCloseCreateChange"
     >
-      <div class="header" slot="header">
-        Create Change
-      </div>
+      <div class="header" slot="header">Create Change</div>
       <div class="main" slot="main">
         <gr-create-change-dialog
           id="createNewChangeModal"
@@ -88,5 +86,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
index efe4012..893efe55 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-repo-commands.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-commands');
 
@@ -31,8 +32,7 @@
     // Note that this probably does not achieve what it is supposed to, because
     // getProjectConfig() is called as soon as the element is attached, so
     // stubbing it here has not effect anymore.
-    repoStub = sinon.stub(element.$.restAPI, 'getProjectConfig')
-        .returns(Promise.resolve({}));
+    repoStub = stubRestApi('getProjectConfig').returns(Promise.resolve({}));
   });
 
   suite('create new change dialog', () => {
@@ -68,7 +68,7 @@
     let alertStub;
 
     setup(() => {
-      createChangeStub = sinon.stub(element.$.restAPI, 'createChange');
+      createChangeStub = stubRestApi('createChange');
       urlStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
       sinon.stub(GerritNav, 'navigateToRelativeUrl');
       handleSpy = sinon.spy(element, '_handleEditRepoConfig');
@@ -118,12 +118,10 @@
       element.repo = 'test';
 
       const response = {status: 404};
-      sinon.stub(
-          element.$.restAPI, 'getProjectConfig')
-          .callsFake((repo, errFn) => {
-            errFn(response);
-          });
-      element.addEventListener('page-error', e => {
+      stubRestApi('getProjectConfig').callsFake((repo, errFn) => {
+        errFn(response);
+      });
+      addListenerForTest(document, 'page-error', e => {
         assert.deepEqual(e.detail.response, response);
         done();
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
index d9d8560..815e3ac 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
@@ -16,32 +16,23 @@
  */
 
 import '../../../styles/shared-styles';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 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 {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {RepoName, DashboardId, DashboardInfo} from '../../../types/common';
-import {ErrorCallback} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {firePageError} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 interface DashboardRef {
   section: string;
   dashboards: DashboardInfo[];
 }
 
-export interface GrRepoDashboards {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-repo-dashboards')
-export class GrRepoDashboards extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRepoDashboards extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -55,6 +46,8 @@
   @property({type: Array})
   _dashboards?: DashboardRef[];
 
+  private readonly restApiService = appContext.restApiService;
+
   _repoChanged(repo?: RepoName) {
     this._loading = true;
     if (!repo) {
@@ -62,16 +55,10 @@
     }
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
-    return this.$.restAPI
+    return this.restApiService
       .getRepoDashboards(repo, errFn)
       .then((res?: DashboardInfo[]) => {
         if (!res) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
index 7cdd10e..51ea417 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
@@ -67,5 +67,4 @@
       </template>
     </tbody>
   </table>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js
index b4d3575..829611d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-repo-dashboards.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-dashboards');
 
@@ -30,7 +31,7 @@
 
   suite('dashboard table', () => {
     setup(() => {
-      sinon.stub(element.$.restAPI, 'getRepoDashboards').returns(
+      stubRestApi('getRepoDashboards').returns(
           Promise.resolve([
             {
               id: 'default:contributor',
@@ -123,13 +124,11 @@
   suite('404', () => {
     test('fires page-error', done => {
       const response = {status: 404};
-      sinon.stub(
-          element.$.restAPI, 'getRepoDashboards')
-          .callsFake((repo, errFn) => {
-            errFn(response);
-          });
+      stubRestApi('getRepoDashboards').callsFake((repo, errFn) => {
+        errFn(response);
+      });
 
-      element.addEventListener('page-error', e => {
+      addListenerForTest(document, 'page-error', e => {
         assert.deepEqual(e.detail.response, response);
         done();
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
index 2fce6e1..bb65f98 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -25,21 +25,14 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-create-pointer-dialog/gr-create-pointer-dialog';
 import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-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 {
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrCreatePointerDialog} from '../gr-create-pointer-dialog/gr-create-pointer-dialog';
 import {
@@ -53,21 +46,21 @@
 import {AppElementRepoParams} from '../../gr-app-types';
 import {PolymerDomRepeatEvent} from '../../../types/types';
 import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
+import {firePageError} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const PGP_START = '-----BEGIN PGP SIGNATURE-----';
 
 export interface GrRepoDetailList {
   $: {
-    restAPI: RestApiService & Element;
     overlay: GrOverlay;
     createOverlay: GrOverlay;
     createNewModal: GrCreatePointerDialog;
   };
 }
 @customElement('gr-repo-detail-list')
-export class GrRepoDetailList extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrRepoDetailList extends ListViewMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -120,10 +113,12 @@
   @property({type: String})
   _revisedRef?: GitRef;
 
+  private readonly restApiService = appContext.restApiService;
+
   _determineIfOwner(repo: RepoName) {
-    return this.$.restAPI
+    return this.restApiService
       .getRepoAccess(repo)
-      .then(access => (this._isOwner = !!access && !!access[repo].is_owner));
+      .then(access => (this._isOwner = !!access?.[repo]?.is_owner));
   }
 
   _paramsChanged(params?: AppElementRepoParams) {
@@ -182,16 +177,11 @@
     this._items = [];
     flush();
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
+
     if (detailType === RepoDetailView.BRANCHES) {
-      return this.$.restAPI
+      return this.restApiService
         .getRepoBranches(filter, repo, itemsPerPage, offset, errFn)
         .then(items => {
           if (!items) {
@@ -201,7 +191,7 @@
           this._loading = false;
         });
     } else if (detailType === RepoDetailView.TAGS) {
-      return this.$.restAPI
+      return this.restApiService
         .getRepoTags(filter, repo, itemsPerPage, offset, errFn)
         .then(items => {
           if (!items) {
@@ -249,7 +239,7 @@
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   _computeEditingClass(isEditing: boolean) {
@@ -277,7 +267,7 @@
   }
 
   _setRepoHead(repo: RepoName, ref: GitRef, e: PolymerDomRepeatEvent<GitRef>) {
-    return this.$.restAPI.setRepoHead(repo, ref).then(res => {
+    return this.restApiService.setRepoHead(repo, ref).then(res => {
       if (res.status < 400) {
         this._isEditing = false;
         e.model.set('item.revision', ref);
@@ -309,7 +299,7 @@
       return Promise.reject(new Error('undefined repo or refName'));
     }
     if (this.detailType === RepoDetailView.BRANCHES) {
-      return this.$.restAPI
+      return this.restApiService
         .deleteRepoBranches(this._repo, this._refName)
         .then(itemDeleted => {
           if (itemDeleted.status === 204) {
@@ -323,7 +313,7 @@
           }
         });
     } else if (this.detailType === RepoDetailView.TAGS) {
-      return this.$.restAPI
+      return this.restApiService
         .deleteRepoTags(this._repo, this._refName)
         .then(itemDeleted => {
           if (itemDeleted.status === 204) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
index 196797f..2ac32c0 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
@@ -91,9 +91,7 @@
           <th class$="tagger topHeader [[_hideIfBranch(detailType)]]">
             Tagger
           </th>
-          <th class="repositoryBrowser topHeader">
-            Repository Browser
-          </th>
+          <th class="repositoryBrowser topHeader">Repository Browser</th>
           <th class="delete topHeader"></th>
         </tr>
         <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
@@ -111,13 +109,9 @@
             <td
               class$="[[detailType]] revision [[_computeCanEditClass(item.ref, detailType, _isOwner)]]"
             >
-              <span class="revisionNoEditing">
-                [[item.revision]]
-              </span>
+              <span class="revisionNoEditing"> [[item.revision]] </span>
               <span class$="revisionEdit [[_computeEditingClass(_isEditing)]]">
-                <span class="revisionWithEditing">
-                  [[item.revision]]
-                </span>
+                <span class="revisionWithEditing"> [[item.revision]] </span>
                 <gr-button
                   link=""
                   on-click="_handleEditRevision"
@@ -220,5 +214,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
index 7727821..990ea4b 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
@@ -20,6 +20,7 @@
 import 'lodash/lodash.js';
 import {page} from '../../../utils/page-wrapper-utils.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-detail-list');
 
@@ -74,18 +75,12 @@
           ref: 'HEAD',
           revision: 'master',
         }].concat(_.times(25, branchGenerator));
-
-        stub('gr-rest-api-interface', {
-          getRepoBranches(num, project, offset) {
-            return Promise.resolve(branches);
-          },
-        });
+        stubRestApi('getRepoBranches').returns(Promise.resolve(branches));
 
         const params = {
           repo: 'test',
           detail: 'branches',
         };
-
         element._paramsChanged(params).then(() => { flush(done); });
       });
 
@@ -118,7 +113,7 @@
 
       test('Edit HEAD button not admin', done => {
         sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        sinon.stub(element.$.restAPI, 'getRepoAccess').returns(
+        stubRestApi('getRepoAccess').returns(
             Promise.resolve({
               test: {is_owner: false},
             }));
@@ -142,7 +137,7 @@
             .querySelector('.revisionWithEditing');
 
         sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        sinon.stub(element.$.restAPI, 'getRepoAccess').returns(
+        stubRestApi('getRepoAccess').returns(
             Promise.resolve({
               test: {is_owner: true},
             }));
@@ -219,7 +214,7 @@
       test('_handleSaveRevision with invalid rev', done => {
         const event = {model: {set: sinon.stub()}};
         element._isEditing = true;
-        sinon.stub(element.$.restAPI, 'setRepoHead').returns(
+        stubRestApi('setRepoHead').returns(
             Promise.resolve({
               status: 400,
             })
@@ -235,7 +230,7 @@
       test('_handleSaveRevision with valid rev', done => {
         const event = {model: {set: sinon.stub()}};
         element._isEditing = true;
-        sinon.stub(element.$.restAPI, 'setRepoHead').returns(
+        stubRestApi('setRepoHead').returns(
             Promise.resolve({
               status: 200,
             })
@@ -257,12 +252,7 @@
     suite('list with less then 25 branches', () => {
       setup(done => {
         branches = _.times(25, branchGenerator);
-
-        stub('gr-rest-api-interface', {
-          getRepoBranches(num, repo, offset) {
-            return Promise.resolve(branches);
-          },
-        });
+        stubRestApi('getRepoBranches').returns(Promise.resolve(branches));
 
         const params = {
           repo: 'test',
@@ -278,40 +268,32 @@
     });
 
     suite('filter', () => {
-      test('_paramsChanged', done => {
-        sinon.stub(
-            element.$.restAPI,
-            'getRepoBranches')
-            .callsFake(() => Promise.resolve(branches));
+      test('_paramsChanged', async () => {
+        const stub = stubRestApi('getRepoBranches').returns(
+            Promise.resolve(branches));
         const params = {
           detail: 'branches',
           repo: 'test',
           filter: 'test',
           offset: 25,
         };
-        element._paramsChanged(params).then(() => {
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[0],
-              'test');
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[1],
-              'test');
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[2],
-              25);
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[3],
-              25);
-          done();
-        });
+        await element._paramsChanged(params);
+        assert.equal(stub.lastCall.args[0], 'test');
+        assert.equal(stub.lastCall.args[1], 'test');
+        assert.equal(stub.lastCall.args[2], 25);
+        assert.equal(stub.lastCall.args[3], 25);
       });
     });
 
     suite('404', () => {
       test('fires page-error', done => {
         const response = {status: 404};
-        sinon.stub(element.$.restAPI, 'getRepoBranches').callsFake(
+        stubRestApi('getRepoBranches').callsFake(
             (filter, repo, reposBranchesPerPage, opt_offset, errFn) => {
               errFn(response);
             });
 
-        element.addEventListener('page-error', e => {
+        addListenerForTest(document, 'page-error', e => {
           assert.deepEqual(e.detail.response, response);
           done();
         });
@@ -360,12 +342,7 @@
     suite('list of repo tags', () => {
       setup(done => {
         tags = _.times(26, tagGenerator);
-
-        stub('gr-rest-api-interface', {
-          getRepoTags(num, repo, offset) {
-            return Promise.resolve(tags);
-          },
-        });
+        stubRestApi('getRepoTags').returns(Promise.resolve(tags));
 
         const params = {
           repo: 'test',
@@ -435,12 +412,7 @@
     suite('list with less then 25 tags', () => {
       setup(done => {
         tags = _.times(25, tagGenerator);
-
-        stub('gr-rest-api-interface', {
-          getRepoTags(num, project, offset) {
-            return Promise.resolve(tags);
-          },
-        });
+        stubRestApi('getRepoTags').returns(Promise.resolve(tags));
 
         const params = {
           repo: 'test',
@@ -456,28 +428,19 @@
     });
 
     suite('filter', () => {
-      test('_paramsChanged', done => {
-        sinon.stub(
-            element.$.restAPI,
-            'getRepoTags')
-            .callsFake(() => Promise.resolve(tags));
+      test('_paramsChanged', async () => {
+        const stub = stubRestApi('getRepoTags').returns(Promise.resolve(tags));
         const params = {
           repo: 'test',
           detail: 'tags',
           filter: 'test',
           offset: 25,
         };
-        element._paramsChanged(params).then(() => {
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[0],
-              'test');
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[1],
-              'test');
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[2],
-              25);
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[3],
-              25);
-          done();
-        });
+        await element._paramsChanged(params);
+        assert.equal(stub.lastCall.args[0], 'test');
+        assert.equal(stub.lastCall.args[1], 'test');
+        assert.equal(stub.lastCall.args[2], 25);
+        assert.equal(stub.lastCall.args[3], 25);
       });
     });
 
@@ -520,12 +483,12 @@
     suite('404', () => {
       test('fires page-error', done => {
         const response = {status: 404};
-        sinon.stub(element.$.restAPI, 'getRepoTags').callsFake(
+        stubRestApi('getRepoTags').callsFake(
             (filter, repo, reposTagsPerPage, opt_offset, errFn) => {
               errFn(response);
             });
 
-        element.addEventListener('page-error', e => {
+        addListenerForTest(document, 'page-error', e => {
           assert.deepEqual(e.detail.response, response);
           done();
         });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index ea1cffb..3bc3bef 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -19,21 +19,19 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 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 {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {RepoName, ProjectInfoWithName} from '../../../types/common';
 import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog';
 import {ProjectState} from '../../../constants/constants';
+import {fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -43,16 +41,13 @@
 
 export interface GrRepoList {
   $: {
-    restAPI: RestApiService & Element;
     createOverlay: GrOverlay;
     createNewModal: GrCreateRepoDialog;
   };
 }
 
 @customElement('gr-repo-list')
-export class GrRepoList extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrRepoList extends ListViewMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -89,17 +84,13 @@
     return this.computeShownItems(this._repos);
   }
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._getCreateRepoCapability();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Repos'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Repos');
     this._maybeOpenCreateOverlay(this.params);
   }
 
@@ -130,11 +121,11 @@
   }
 
   _getCreateRepoCapability() {
-    return this.$.restAPI.getAccount().then(account => {
+    return this.restApiService.getAccount().then(account => {
       if (!account) {
         return;
       }
-      return this.$.restAPI
+      return this.restApiService
         .getAccountCapabilities(['createProject'])
         .then(capabilities => {
           if (capabilities?.createProject) {
@@ -146,20 +137,22 @@
 
   _getRepos(filter: string, reposPerPage: number, offset?: number) {
     this._repos = [];
-    return this.$.restAPI.getRepos(filter, reposPerPage, offset).then(repos => {
-      // Late response.
-      if (filter !== this._filter || !repos) {
-        return;
-      }
-      this._repos = repos.filter(repo =>
-        repo.name.toLowerCase().includes(filter.toLowerCase())
-      );
-      this._loading = false;
-    });
+    return this.restApiService
+      .getRepos(filter, reposPerPage, offset)
+      .then(repos => {
+        // Late response.
+        if (filter !== this._filter || !repos) {
+          return;
+        }
+        this._repos = repos.filter(repo =>
+          repo.name.toLowerCase().includes(filter.toLowerCase())
+        );
+        this._loading = false;
+      });
   }
 
   _refreshReposList() {
-    this.$.restAPI.invalidateReposCache();
+    this.restApiService.invalidateReposCache();
     return this._getRepos(this._filter, this._reposPerPage, this._offset);
   }
 
@@ -174,7 +167,9 @@
   }
 
   _handleCreateClicked() {
-    this.$.createOverlay.open();
+    this.$.createOverlay.open().then(() => {
+      this.$.createNewModal.focus();
+    });
   }
 
   _readOnly(repo: ProjectInfoWithName) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
index 4889845..e1a7f489 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
@@ -104,9 +104,7 @@
       on-confirm="_handleCreateRepo"
       on-cancel="_handleCloseCreate"
     >
-      <div class="header" slot="header">
-        Create Repository
-      </div>
+      <div class="header" slot="header">Create Repository</div>
       <div class="main" slot="main">
         <gr-create-repo-dialog
           has-new-repo-name="{{_hasNewRepoName}}"
@@ -115,5 +113,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
index ad8c6dd..4864af5 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
@@ -19,6 +19,7 @@
 import './gr-repo-list.js';
 import {page} from '../../../utils/page-wrapper-utils.js';
 import 'lodash/lodash.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-list');
 
@@ -54,11 +55,7 @@
   suite('list with repos', () => {
     setup(done => {
       repos = _.times(26, repoGenerator);
-      stub('gr-rest-api-interface', {
-        getRepos(num, offset) {
-          return Promise.resolve(repos);
-        },
-      });
+      stubRestApi('getRepos').returns(Promise.resolve(repos));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -89,13 +86,7 @@
   suite('list with less then 25 repos', () => {
     setup(done => {
       repos = _.times(25, repoGenerator);
-
-      stub('gr-rest-api-interface', {
-        getRepos(num, offset) {
-          return Promise.resolve(repos);
-        },
-      });
-
+      stubRestApi('getRepos').returns(Promise.resolve(repos));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -111,22 +102,19 @@
       reposFiltered = _.times(1, repoGenerator);
     });
 
-    test('_paramsChanged', done => {
-      sinon.stub(element.$.restAPI, 'getRepos')
-          .callsFake( () => Promise.resolve(repos));
+    test('_paramsChanged', async () => {
+      const repoStub = stubRestApi('getRepos');
+      repoStub.returns(Promise.resolve(repos));
       const value = {
         filter: 'test',
         offset: 25,
       };
-      element._paramsChanged(value).then(() => {
-        assert.isTrue(element.$.restAPI.getRepos.lastCall
-            .calledWithExactly('test', 25, 25));
-        done();
-      });
+      await element._paramsChanged(value);
+      assert.isTrue(repoStub.lastCall.calledWithExactly('test', 25, 25));
     });
 
     test('latest repos requested are always set', done => {
-      const repoStub = sinon.stub(element.$.restAPI, 'getRepos');
+      const repoStub = stubRestApi('getRepos');
       repoStub.withArgs('test').returns(Promise.resolve(repos));
       repoStub.withArgs('filter').returns(Promise.resolve(reposFiltered));
       element._filter = 'test';
@@ -139,7 +127,7 @@
     });
 
     test('filter is case insensitive', async () => {
-      const repoStub = sinon.stub(element.$.restAPI, 'getRepos');
+      const repoStub = stubRestApi('getRepos');
       const repos = [createRepo('aSDf', 0)];
       repoStub.withArgs('asdf').returns(Promise.resolve(repos));
       element._filter = 'asdf';
@@ -175,7 +163,8 @@
     });
 
     test('_handleCreateClicked opens modal', () => {
-      const openStub = sinon.stub(element.$.createOverlay, 'open');
+      const openStub = sinon.stub(element.$.createOverlay, 'open').returns(
+          Promise.resolve());
       element._handleCreateClicked();
       assert.isTrue(openStub.called);
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
index e7eb9dc..ef35f63 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
@@ -24,8 +24,6 @@
 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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-plugin-config_html';
 import {customElement, property} from '@polymer/decorators';
@@ -62,9 +60,7 @@
 }
 
 @customElement('gr-repo-plugin-config')
-class GrRepoPluginConfig extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrRepoPluginConfig extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
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
index 208e042..2920cdf 100644
--- 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
@@ -64,7 +64,7 @@
                 on-change="_handleBooleanChange"
                 data-option-key$="[[option._key]]"
                 disabled$="[[_computeDisabled(disabled, option.info.editable)]]"
-                on-tap="_onTapPluginBoolean"
+                on-click="_onTapPluginBoolean"
               ></paper-toggle-button>
             </template>
             <template is="dom-if" if="[[_isList(option.info.type)]]">
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 101c77a..dc28bcb 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -19,23 +19,16 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-download-commands/gr-download-commands';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
 import '../../../styles/gr-form-styles';
 import '../../../styles/gr-subpage-styles';
 import '../../../styles/shared-styles';
 import '../gr-repo-plugin-config/gr-repo-plugin-config';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property, observe} from '@polymer/decorators';
 import {
-  RestApiService,
-  ErrorCallback,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {
   ConfigInfo,
   RepoName,
   InheritedBooleanInfo,
@@ -48,6 +41,10 @@
 import {ProjectState} from '../../../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {WebLinkInfo} from '../../../types/diff';
+import {ErrorCallback} from '../../../api/rest';
 
 const STATES = {
   active: {value: ProjectState.ACTIVE, label: 'Active'},
@@ -83,15 +80,8 @@
   },
 };
 
-export interface GrRepo {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-repo')
-export class GrRepo extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRepo extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -144,18 +134,17 @@
   @property({type: Object})
   _schemesObj?: SchemesInfoMap;
 
+  @property({type: Array})
+  weblinks: WebLinkInfo[] = [];
+
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadRepo();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: this.repo},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, `${this.repo}`);
   }
 
   _computePluginData(
@@ -182,13 +171,7 @@
     const promises = [];
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
     promises.push(
@@ -197,7 +180,11 @@
         if (loggedIn) {
           const repo = this.repo;
           if (!repo) throw new Error('undefined repo');
-          this.$.restAPI.getRepoAccess(repo).then(access => {
+          this.restApiService.getRepo(repo).then(repo => {
+            if (!repo?.web_links) return;
+            this.weblinks = repo.web_links;
+          });
+          this.restApiService.getRepoAccess(repo).then(access => {
             if (!access || this.repo !== repo) {
               return;
             }
@@ -210,7 +197,7 @@
     );
 
     promises.push(
-      this.$.restAPI.getProjectConfig(this.repo, errFn).then(config => {
+      this.restApiService.getProjectConfig(this.repo, errFn).then(config => {
         if (!config) {
           return;
         }
@@ -232,7 +219,7 @@
     );
 
     promises.push(
-      this.$.restAPI.getConfig().then(config => {
+      this.restApiService.getConfig().then(config => {
         if (!config) {
           return;
         }
@@ -256,7 +243,7 @@
     if (!_loggedIn) {
       return;
     }
-    this.$.restAPI.getPreferences().then(prefs => {
+    this.restApiService.getPreferences().then(prefs => {
       if (prefs?.download_scheme) {
         // Note (issue 5180): normalize the download scheme with lower-case.
         this._selectedScheme = prefs.download_scheme.toLowerCase();
@@ -324,7 +311,7 @@
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   _formatRepoConfigForSave(repoConfig: ConfigInfo): ConfigInput {
@@ -340,12 +327,14 @@
       if (key === 'plugin_config') {
         configInputObj.plugin_config_values = repoConfig.plugin_config;
       } else if (typeof repoConfig[key] === 'object') {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
         const repoConfigObj: any = repoConfig[key];
         if (repoConfigObj.configured_value) {
           configInputObj[key as keyof ConfigInput] =
             repoConfigObj.configured_value;
         }
       } else {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
         configInputObj[key as keyof ConfigInput] = repoConfig[key] as any;
       }
     }
@@ -355,7 +344,7 @@
   _handleSaveRepoConfig() {
     if (!this._repoConfig || !this.repo)
       return Promise.reject(new Error('undefined repoConfig or repo'));
-    return this.$.restAPI
+    return this.restApiService
       .saveRepoConfig(
         this.repo,
         this._formatRepoConfigForSave(this._repoConfig)
@@ -399,21 +388,14 @@
     schemesObj?: SchemesInfoMap,
     _selectedScheme?: string
   ) {
-    if (!schemesObj || !repo || !_selectedScheme) {
-      return [];
-    }
+    if (!schemesObj || !repo || !_selectedScheme) return [];
+    if (!hasOwnProperty(schemesObj, _selectedScheme)) return [];
+    const commandObj = schemesObj[_selectedScheme].clone_commands;
     const commands = [];
-    let commandObj: {[title: string]: string} = {};
-    if (hasOwnProperty(schemesObj, _selectedScheme)) {
-      commandObj = schemesObj[_selectedScheme].clone_commands;
-    }
-    for (const title in commandObj) {
-      if (!hasOwnProperty(commandObj, title)) {
-        continue;
-      }
+    for (const [title, command] of Object.entries(commandObj)) {
       commands.push({
         title,
-        command: commandObj[title]
+        command: command
           .replace(/\${project}/gi, encodeURI(repo))
           .replace(
             /\${project-base-name}/gi,
@@ -432,6 +414,10 @@
     return GerritNav.getUrlForProjectChanges(name);
   }
 
+  _computeBrowseUrl(weblinks: WebLinkInfo[]) {
+    return weblinks?.[0]?.url;
+  }
+
   _handlePluginConfigChanged({
     detail: {name, config, notifyPath},
   }: {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
index 3d96f98..8db498a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
@@ -45,17 +45,21 @@
   <style include="gr-form-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
-  <main class="gr-form-styles read-only">
+  <div class="main gr-form-styles read-only">
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
     <div class="info">
-      <h1 id="Title" class="heading-1">
-        [[repo]]
-      </h1>
+      <h1 id="Title" class="heading-1">[[repo]]</h1>
       <hr />
       <div>
-        <a href$="[[_computeChangesUrl(repo)]]">(view changes)</a>
+        <a href$="[[_computeBrowseUrl(weblinks)]]"
+          ><gr-button link disabled="[[!_computeBrowseUrl(weblinks)]]"
+            >Browse</gr-button
+          ></a
+        ><a href$="[[_computeChangesUrl(repo)]]"
+          ><gr-button link>View Changes</gr-button></a
+        >
       </div>
     </div>
     <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
@@ -436,6 +440,5 @@
         </gr-endpoint-decorator>
       </div>
     </div>
-  </main>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </div>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
index 93a9d64..c299b55 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
@@ -18,12 +18,13 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-repo.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo');
 
 suite('gr-repo tests', () => {
   let element;
-
+  let loggedInStub;
   let repoStub;
   const repoConf = {
     description: 'Access inherited by all other projects.',
@@ -98,17 +99,11 @@
   }
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      getConfig() {
-        return Promise.resolve({download: {}});
-      },
-    });
+    loggedInStub = stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getConfig').returns(Promise.resolve({download: {}}));
+    repoStub =
+        stubRestApi('getProjectConfig').returns(Promise.resolve(repoConf));
     element = basicFixture.instantiate();
-    repoStub = sinon.stub(
-        element.$.restAPI,
-        'getProjectConfig')
-        .callsFake(() => Promise.resolve(repoConf));
   });
 
   test('_computePluginData', () => {
@@ -159,37 +154,28 @@
     assert.isTrue(element._readOnly);
   });
 
-  test('form defaults to read only when not logged in', done => {
+  test('form defaults to read only when not logged in', async () => {
     element.repo = REPO;
-    element._loadRepo().then(() => {
-      assert.isTrue(element._readOnly);
-      done();
-    });
+    await element._loadRepo();
+    assert.isTrue(element._readOnly);
   });
 
-  test('form defaults to read only when logged in and not admin', done => {
+  test('form defaults to read only when logged in and not admin', async () => {
     element.repo = REPO;
-    sinon.stub(element, '_getLoggedIn').callsFake(() => Promise.resolve(true));
-    sinon.stub(
-        element.$.restAPI,
-        'getRepoAccess')
+    stubRestApi('getRepoAccess')
         .callsFake(() => Promise.resolve({'test-repo': {}}));
-    element._loadRepo().then(() => {
-      assert.isTrue(element._readOnly);
-      done();
-    });
+    await element._loadRepo();
+    assert.isTrue(element._readOnly);
   });
 
-  test('all form elements are disabled when not admin', done => {
+  test('all form elements are disabled when not admin', async () => {
     element.repo = REPO;
-    element._loadRepo().then(() => {
-      flush();
-      const formFields = getFormFields();
-      for (const field of formFields) {
-        assert.isTrue(field.hasAttribute('disabled'));
-      }
-      done();
-    });
+    await element._loadRepo();
+    flush();
+    const formFields = getFormFields();
+    for (const field of formFields) {
+      assert.isTrue(field.hasAttribute('disabled'));
+    }
   });
 
   test('_formatBooleanSelect', () => {
@@ -246,11 +232,10 @@
     element.repo = 'test';
 
     const response = {status: 404};
-    sinon.stub(
-        element.$.restAPI, 'getProjectConfig').callsFake((repo, errFn) => {
+    stubRestApi('getProjectConfig').callsFake((repo, errFn) => {
       errFn(response);
     });
-    element.addEventListener('page-error', e => {
+    addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
       done();
     });
@@ -261,48 +246,38 @@
   suite('admin', () => {
     setup(() => {
       element.repo = REPO;
-      sinon.stub(element, '_getLoggedIn')
-          .callsFake(() => Promise.resolve(true));
-      sinon.stub(
-          element.$.restAPI,
-          'getRepoAccess')
-          .callsFake(() => Promise.resolve({'test-repo': {is_owner: true}}));
+      loggedInStub.returns(Promise.resolve(true));
+      stubRestApi('getRepoAccess')
+          .returns(Promise.resolve({'test-repo': {is_owner: true}}));
     });
 
-    test('all form elements are enabled', done => {
-      element._loadRepo().then(() => {
-        flush();
-        const formFields = getFormFields();
-        for (const field of formFields) {
-          assert.isFalse(field.hasAttribute('disabled'));
-        }
-        assert.isFalse(element._loading);
-        done();
-      });
+    test('all form elements are enabled', async () => {
+      await element._loadRepo();
+      await flush();
+      const formFields = getFormFields();
+      for (const field of formFields) {
+        assert.isFalse(field.hasAttribute('disabled'));
+      }
+      assert.isFalse(element._loading);
     });
 
-    test('state gets set correctly', done => {
-      element._loadRepo().then(() => {
-        assert.equal(element._repoConfig.state, 'ACTIVE');
-        assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
-        done();
-      });
+    test('state gets set correctly', async () => {
+      await element._loadRepo();
+      assert.equal(element._repoConfig.state, 'ACTIVE');
+      assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
     });
 
-    test('inherited submit type value is calculated correctly', done => {
-      element
-          ._loadRepo().then(() => {
-            const sel = element.$.submitTypeSelect;
-            assert.equal(sel.bindValue, 'INHERIT');
-            assert.equal(
-                sel.nativeSelect.options[0].text,
-                'Inherit (Merge if necessary)'
-            );
-            done();
-          });
+    test('inherited submit type value is calculated correctly', async () => {
+      await element._loadRepo();
+      const sel = element.$.submitTypeSelect;
+      assert.equal(sel.bindValue, 'INHERIT');
+      assert.equal(
+          sel.nativeSelect.options[0].text,
+          'Inherit (Merge if necessary)'
+      );
     });
 
-    test('fields update and save correctly', () => {
+    test('fields update and save correctly', async () => {
       const configInputObj = {
         description: 'new description',
         use_contributor_agreements: 'TRUE',
@@ -322,59 +297,57 @@
         enable_reviewer_by_email: 'TRUE',
       };
 
-      const saveStub = sinon.stub(element.$.restAPI, 'saveRepoConfig')
+      const saveStub = stubRestApi('saveRepoConfig')
           .callsFake(() => Promise.resolve({}));
 
-      const button = element.root.querySelector('gr-button');
+      const button = element.root.querySelectorAll('gr-button')[2];
 
-      return element._loadRepo().then(() => {
-        assert.isTrue(button.hasAttribute('disabled'));
-        assert.isFalse(element.$.Title.classList.contains('edited'));
-        element.$.descriptionInput.bindValue = configInputObj.description;
-        element.$.stateSelect.bindValue = configInputObj.state;
-        element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
-        element.$.contentMergeSelect.bindValue =
-            configInputObj.use_content_merge;
-        element.$.newChangeSelect.bindValue =
-            configInputObj.create_new_change_for_all_not_in_target;
-        element.$.requireChangeIdSelect.bindValue =
-            configInputObj.require_change_id;
-        element.$.enableSignedPush.bindValue =
-            configInputObj.enable_signed_push;
-        element.$.requireSignedPush.bindValue =
-            configInputObj.require_signed_push;
-        element.$.rejectImplicitMergesSelect.bindValue =
-            configInputObj.reject_implicit_merges;
-        element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
-            configInputObj.private_by_default;
-        element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
-            configInputObj.match_author_to_committer_date;
-        const inputElement = PolymerElement ?
-          element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
-        inputElement.bindValue = configInputObj.max_object_size_limit;
-        element.$.contributorAgreementSelect.bindValue =
-            configInputObj.use_contributor_agreements;
-        element.$.useSignedOffBySelect.bindValue =
-            configInputObj.use_signed_off_by;
-        element.$.rejectEmptyCommitSelect.bindValue =
-            configInputObj.reject_empty_commit;
-        element.$.unRegisteredCcSelect.bindValue =
-            configInputObj.enable_reviewer_by_email;
+      await element._loadRepo();
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+      element.$.descriptionInput.bindValue = configInputObj.description;
+      element.$.stateSelect.bindValue = configInputObj.state;
+      element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
+      element.$.contentMergeSelect.bindValue =
+          configInputObj.use_content_merge;
+      element.$.newChangeSelect.bindValue =
+          configInputObj.create_new_change_for_all_not_in_target;
+      element.$.requireChangeIdSelect.bindValue =
+          configInputObj.require_change_id;
+      element.$.enableSignedPush.bindValue =
+          configInputObj.enable_signed_push;
+      element.$.requireSignedPush.bindValue =
+          configInputObj.require_signed_push;
+      element.$.rejectImplicitMergesSelect.bindValue =
+          configInputObj.reject_implicit_merges;
+      element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
+          configInputObj.private_by_default;
+      element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
+          configInputObj.match_author_to_committer_date;
+      const inputElement = PolymerElement ?
+        element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
+      inputElement.bindValue = configInputObj.max_object_size_limit;
+      element.$.contributorAgreementSelect.bindValue =
+          configInputObj.use_contributor_agreements;
+      element.$.useSignedOffBySelect.bindValue =
+          configInputObj.use_signed_off_by;
+      element.$.rejectEmptyCommitSelect.bindValue =
+          configInputObj.reject_empty_commit;
+      element.$.unRegisteredCcSelect.bindValue =
+          configInputObj.enable_reviewer_by_email;
 
-        assert.isFalse(button.hasAttribute('disabled'));
-        assert.isTrue(element.$.configurations.classList.contains('edited'));
+      assert.isFalse(button.hasAttribute('disabled'));
+      assert.isTrue(element.$.configurations.classList.contains('edited'));
 
-        const formattedObj =
-            element._formatRepoConfigForSave(element._repoConfig);
-        assert.deepEqual(formattedObj, configInputObj);
+      const formattedObj =
+          element._formatRepoConfigForSave(element._repoConfig);
+      assert.deepEqual(formattedObj, configInputObj);
 
-        return element._handleSaveRepoConfig().then(() => {
-          assert.isTrue(button.hasAttribute('disabled'));
-          assert.isFalse(element.$.Title.classList.contains('edited'));
-          assert.isTrue(saveStub.lastCall.calledWithExactly(REPO,
-              configInputObj));
-        });
-      });
+      await element._handleSaveRepoConfig();
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+      assert.isTrue(saveStub.lastCall.calledWithExactly(REPO,
+          configInputObj));
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index 8843933..37e0596 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -19,14 +19,12 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-rule-editor_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {AccessPermissionId} from '../../../utils/access-util';
 import {property, customElement, observe} from '@polymer/decorators';
+import {fireEvent} from '../../../utils/event-util';
 
 /**
  * Fired when the rule has been modified or removed.
@@ -102,9 +100,7 @@
 }
 
 @customElement('gr-rule-editor')
-export class GrRuleEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRuleEditor extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -140,9 +136,8 @@
   @property({type: Object})
   _originalRuleValues?: RuleValue;
 
-  /** @override */
-  created() {
-    super.created();
+  constructor() {
+    super();
     this.addEventListener('access-saved', () => this._handleAccessSaved());
   }
 
@@ -158,8 +153,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     // Check needed for test purposes.
     if (!this._originalRuleValues && this.rule) {
       // Observer _handleValueChange is called after the ready()
@@ -268,15 +263,11 @@
   _handleRemoveRule() {
     if (!this.rule) return;
     if (this.rule.value.added) {
-      this.dispatchEvent(
-        new CustomEvent('added-rule-removed', {bubbles: true, composed: true})
-      );
+      fireEvent(this, 'added-rule-removed');
     }
     this._deleted = true;
     this.rule.value.deleted = true;
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _handleUndoRemove() {
@@ -305,9 +296,7 @@
     }
     this.rule.value.modified = true;
     // Allows overall access page to know a change has been made.
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _setOriginalRuleValues(value: RuleValue) {
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
index 98403e0..c4d7688 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
@@ -156,5 +156,4 @@
       >Undo</gr-button
     >
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
index b0065d9..9c3646a 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
@@ -192,12 +192,12 @@
       };
       element.section = 'refs/*';
 
-      // Typically called on ready since elements will have properies defined
+      // Typically called on ready since elements will have properties defined
       // by the parent element.
       element._setupValues(element.rule);
       flush();
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -306,7 +306,7 @@
       flush();
       element.rule.value.added = true;
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -371,7 +371,7 @@
       element._setupValues(element.rule);
       flush();
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -425,7 +425,7 @@
       flush();
       element.rule.value.added = true;
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -482,7 +482,7 @@
       element._setupValues(element.rule);
       flush();
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -524,7 +524,7 @@
       flush();
       element.rule.value.added = true;
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -571,7 +571,7 @@
       element._setupValues(element.rule);
       flush();
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index d70e891..2869750 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
@@ -26,8 +26,6 @@
 import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-list-item_html';
 import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
@@ -38,7 +36,7 @@
 import {appContext} from '../../../services/app-context';
 import {truncatePath} from '../../../utils/path-list-util';
 import {changeStatuses} from '../../../utils/change-util';
-import {isServiceUser} from '../../../utils/account-util';
+import {isSelf, isServiceUser} from '../../../utils/account-util';
 import {customElement, property} from '@polymer/decorators';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {
@@ -49,6 +47,7 @@
   Timestamp,
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {pluralize} from '../../../utils/string-util';
 
 enum ChangeSize {
   XS = 10,
@@ -77,9 +76,7 @@
 const PRIMARY_REVIEWERS_COUNT = 2;
 
 @customElement('gr-change-list-item')
-export class GrChangeListItem extends ChangeTableMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrChangeListItem extends ChangeTableMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -125,8 +122,8 @@
   reporting: ReportingService = appContext.reportingService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -152,15 +149,18 @@
     if (!label || category === LabelCategory.NOT_APPLICABLE) {
       return 'Label not applicable';
     }
+    const titleParts: string[] = [];
     if (category === LabelCategory.UNRESOLVED_COMMENTS) {
       const num = change?.unresolved_comment_count ?? 0;
-      const plural = num > 1 ? 's' : '';
-      return `${num} unresolved comment${plural}`;
+      titleParts.push(pluralize(num, 'unresolved comment'));
     }
     const significantLabel =
       label.rejected || label.approved || label.disliked || label.recommended;
-    if (significantLabel && significantLabel.name) {
-      return `${labelName}\nby ${significantLabel.name}`;
+    if (significantLabel?.name) {
+      titleParts.push(`${labelName} by ${significantLabel.name}`);
+    }
+    if (titleParts.length > 0) {
+      return titleParts.join(',\n');
     }
     return labelName;
   }
@@ -329,8 +329,8 @@
     );
     reviewers.sort((r1, r2) => {
       if (this.account) {
-        if (r1._account_id === this.account._account_id) return -1;
-        if (r2._account_id === this.account._account_id) return 1;
+        if (isSelf(r1, this.account)) return -1;
+        if (isSelf(r2, this.account)) return 1;
       }
       if (this._hasAttention(r1) && !this._hasAttention(r2)) return -1;
       if (this._hasAttention(r2) && !this._hasAttention(r1)) return 1;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
index fdb4534..2f07268 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
@@ -54,7 +54,7 @@
       white-space: nowrap;
     }
     .reviewers {
-      --account-max-length: 90px;
+      --account-max-length: 70px;
     }
     .spacer {
       height: 0;
@@ -84,6 +84,9 @@
     a:hover {
       text-decoration: underline;
     }
+    .subject:hover .content {
+      text-decoration: underline;
+    }
     .u-monospace {
       font-family: var(--monospace-font-family);
       font-size: var(--font-size-mono);
@@ -130,21 +133,17 @@
     class="cell subject"
     hidden$="[[isColumnHidden('Subject', visibleChangeTableColumns)]]"
   >
-    <div class="container">
-      <div class="content">
-        <a
-          title$="[[change.subject]]"
-          href$="[[changeURL]]"
-          on-click="_handleChangeClick"
-        >
-          [[change.subject]]
-        </a>
+    <a
+      title$="[[change.subject]]"
+      href$="[[changeURL]]"
+      on-click="_handleChangeClick"
+    >
+      <div class="container">
+        <div class="content">[[change.subject]]</div>
+        <div class="spacer">[[change.subject]]</div>
+        <span>&nbsp;</span>
       </div>
-      <div class="spacer">
-        [[change.subject]]
-      </div>
-      <span>&nbsp;</span>
-    </div>
+    </a>
   </td>
   <td
     class="cell status"
@@ -243,9 +242,7 @@
     class="cell branch"
     hidden$="[[isColumnHidden('Branch', visibleChangeTableColumns)]]"
   >
-    <a href$="[[_computeRepoBranchURL(change)]]">
-      [[change.branch]]
-    </a>
+    <a href$="[[_computeRepoBranchURL(change)]]"> [[change.branch]] </a>
     <template is="dom-if" if="[[change.topic]]">
       (<a href$="[[_computeTopicURL(change)]]"
         ><!--
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
deleted file mode 100644
index d3274f3..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
+++ /dev/null
@@ -1,358 +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-change-list-item.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {LabelCategory} from './gr-change-list-item.js';
-
-const basicFixture = fixtureFromElement('gr-change-list-item');
-
-suite('gr-change-list-item tests', () => {
-  let element;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getLoggedIn() { return Promise.resolve(false); },
-    });
-    element = basicFixture.instantiate();
-  });
-
-  test('_computeLabelCategory', () => {
-    assert.equal(element._computeLabelCategory({labels: {}}),
-        LabelCategory.NOT_APPLICABLE);
-    assert.equal(element._computeLabelCategory(
-        {labels: {}}, 'Verified'), LabelCategory.NOT_APPLICABLE);
-    assert.equal(element._computeLabelCategory(
-        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
-    LabelCategory.APPROVED);
-    assert.equal(element._computeLabelCategory(
-        {labels: {Verified: {rejected: true, value: -1}}}, 'Verified'),
-    LabelCategory.REJECTED);
-    assert.equal(element._computeLabelCategory(
-        {
-          labels: {'Code-Review': {approved: true, value: 1}},
-          unresolved_comment_count: 1,
-        }, 'Code-Review'),
-    LabelCategory.UNRESOLVED_COMMENTS);
-    assert.equal(element._computeLabelCategory(
-        {labels: {'Code-Review': {value: 1}}}, 'Code-Review'),
-    LabelCategory.POSITIVE);
-    assert.equal(element._computeLabelCategory(
-        {labels: {'Code-Review': {value: -1}}}, 'Code-Review'),
-    LabelCategory.NEGATIVE);
-    assert.equal(element._computeLabelCategory(
-        {labels: {'Code-Review': {value: -1}}}, 'Verified'),
-    LabelCategory.NOT_APPLICABLE);
-  });
-
-  test('_computeLabelClass', () => {
-    assert.equal(element._computeLabelClass({labels: {}}),
-        'cell label u-gray-background');
-    assert.equal(element._computeLabelClass(
-        {labels: {}}, 'Verified'), 'cell label u-gray-background');
-    assert.equal(element._computeLabelClass(
-        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
-    'cell label u-green');
-    assert.equal(element._computeLabelClass(
-        {labels: {Verified: {rejected: true, value: -1}}}, 'Verified'),
-    'cell label u-red');
-    assert.equal(element._computeLabelClass(
-        {labels: {'Code-Review': {value: 1}}}, 'Code-Review'),
-    'cell label u-green u-monospace');
-    assert.equal(element._computeLabelClass(
-        {labels: {'Code-Review': {value: -1}}}, 'Code-Review'),
-    'cell label u-monospace u-red');
-    assert.equal(element._computeLabelClass(
-        {labels: {'Code-Review': {value: -1}}}, 'Verified'),
-    'cell label u-gray-background');
-  });
-
-  test('_computeLabelTitle', () => {
-    assert.equal(element._computeLabelTitle({labels: {}}, 'Verified'),
-        'Label not applicable');
-    assert.equal(element._computeLabelTitle(
-        {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Verified'),
-    'Verified\nby Diffy');
-    assert.equal(element._computeLabelTitle(
-        {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Code-Review'),
-    'Label not applicable');
-    assert.equal(element._computeLabelTitle(
-        {labels: {Verified: {rejected: {name: 'Diffy'}}}}, 'Verified'),
-    'Verified\nby Diffy');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {disliked: {name: 'Diffy'}, value: -1}}},
-        'Code-Review'), 'Code-Review\nby Diffy');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {recommended: {name: 'Diffy'}, value: 1}}},
-        'Code-Review'), 'Code-Review\nby Diffy');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {recommended: {name: 'Diffy'},
-          rejected: {name: 'Admin'}}}}, 'Code-Review'),
-    'Code-Review\nby Admin');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {approved: {name: 'Diffy'},
-          rejected: {name: 'Admin'}}}}, 'Code-Review'),
-    'Code-Review\nby Admin');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {recommended: {name: 'Diffy'},
-          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
-    'Code-Review\nby Admin');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {approved: {name: 'Diffy'},
-          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
-    'Code-Review\nby Diffy');
-    assert.equal(element._computeLabelTitle(
-        {
-          labels: {'Code-Review': {approved: true, value: 1}},
-          unresolved_comment_count: 1,
-        }, 'Code-Review'),
-    '1 unresolved comment');
-    assert.equal(element._computeLabelTitle(
-        {
-          labels: {'Code-Review': {approved: true, value: 1}},
-          unresolved_comment_count: 2,
-        }, 'Code-Review'),
-    '2 unresolved comments');
-  });
-
-  test('_computeLabelIcon', () => {
-    assert.equal(element._computeLabelIcon({labels: {}}), '');
-    assert.equal(element._computeLabelIcon(
-        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
-    'gr-icons:check');
-    assert.equal(element._computeLabelIcon(
-        {
-          labels: {'Code-Review': {approved: true, value: 1}},
-          unresolved_comment_count: 1,
-        }, 'Code-Review'),
-    'gr-icons:comment');
-  });
-
-  test('_computeLabelValue', () => {
-    assert.equal(element._computeLabelValue({labels: {}}), '');
-    assert.equal(element._computeLabelValue({labels: {}}, 'Verified'), '');
-    assert.equal(element._computeLabelValue(
-        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'), '✓');
-    assert.equal(element._computeLabelValue(
-        {labels: {Verified: {value: 1}}}, 'Verified'), '+1');
-    assert.equal(element._computeLabelValue(
-        {labels: {Verified: {value: -1}}}, 'Verified'), '-1');
-    assert.equal(element._computeLabelValue(
-        {labels: {Verified: {approved: true}}}, 'Verified'), '✓');
-    assert.equal(element._computeLabelValue(
-        {labels: {Verified: {rejected: true}}}, 'Verified'), '✕');
-  });
-
-  test('no hidden columns', () => {
-    element.visibleChangeTableColumns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Updated',
-      'Size',
-    ];
-
-    flush();
-
-    for (const column of element.columnNames) {
-      const elementClass = '.' + column.toLowerCase();
-      assert.isOk(element.shadowRoot
-          .querySelector(elementClass),
-      `Expect ${elementClass} element to be found`);
-      assert.isFalse(element.shadowRoot
-          .querySelector(elementClass).hidden);
-    }
-  });
-
-  test('repo column hidden', () => {
-    element.visibleChangeTableColumns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Branch',
-      'Updated',
-      'Size',
-    ];
-
-    flush();
-
-    for (const column of element.columnNames) {
-      const elementClass = '.' + column.toLowerCase();
-      if (column === 'Repo') {
-        assert.isTrue(element.shadowRoot
-            .querySelector(elementClass).hidden);
-      } else {
-        assert.isFalse(element.shadowRoot
-            .querySelector(elementClass).hidden);
-      }
-    }
-  });
-
-  function checkComputeReviewers(
-      userId, reviewerIds, reviewerNames, attSetIds, expected) {
-    element.account = userId ? {_account_id: userId} : null;
-    element.change = {
-      owner: {
-        _account_id: 99,
-      },
-      reviewers: {
-        REVIEWER: [],
-      },
-      attention_set: {},
-    };
-    for (let i = 0; i < reviewerIds.length; i++) {
-      element.change.reviewers.REVIEWER.push({
-        _account_id: reviewerIds[i],
-        name: reviewerNames[i],
-      });
-    }
-    attSetIds.forEach(id => element.change.attention_set[id] = {});
-
-    const actual = element._computeReviewers(element.change)
-        .map(r => r._account_id);
-    assert.deepEqual(actual, expected);
-  }
-
-  test('compute reviewers', () => {
-    checkComputeReviewers(null, [], [], [], []);
-    checkComputeReviewers(1, [], [], [], []);
-    checkComputeReviewers(1, [2], ['a'], [], [2]);
-    checkComputeReviewers(1, [2, 3], [undefined, 'a'], [], [2, 3]);
-    checkComputeReviewers(1, [2, 3], ['a', undefined], [], [3, 2]);
-    checkComputeReviewers(1, [99], ['owner'], [], []);
-    checkComputeReviewers(
-        1, [2, 3, 4, 5], ['b', 'a', 'd', 'c'], [3, 4], [3, 4, 2, 5]);
-    checkComputeReviewers(
-        1, [2, 3, 1, 4, 5], ['b', 'a', 'x', 'd', 'c'], [3, 4], [1, 3, 4, 2, 5]);
-  });
-
-  test('random column does not exist', () => {
-    element.visibleChangeTableColumns = [
-      'Bad',
-    ];
-
-    flush();
-    const elementClass = '.bad';
-    assert.isNotOk(element.shadowRoot
-        .querySelector(elementClass));
-  });
-
-  test('assignee only displayed if there is one', () => {
-    element.change = {};
-    flush();
-    assert.isNotOk(element.shadowRoot
-        .querySelector('.assignee gr-account-link'));
-    assert.equal(element.shadowRoot
-        .querySelector('.assignee').textContent.trim(), '--');
-    element.change = {
-      assignee: {
-        name: 'test',
-        status: 'test',
-      },
-    };
-    flush();
-    assert.isOk(element.shadowRoot
-        .querySelector('.assignee gr-account-link'));
-  });
-
-  test('TShirt sizing tooltip', () => {
-    assert.equal(element._computeSizeTooltip({
-      insertions: 'foo',
-      deletions: 'bar',
-    }), 'Size unknown');
-    assert.equal(element._computeSizeTooltip({
-      insertions: 0,
-      deletions: 0,
-    }), 'Size unknown');
-    assert.equal(element._computeSizeTooltip({
-      insertions: 1,
-      deletions: 2,
-    }), 'added 1, removed 2 lines');
-  });
-
-  test('TShirt sizing', () => {
-    assert.equal(element._computeChangeSize({
-      insertions: 'foo',
-      deletions: 'bar',
-    }), null);
-    assert.equal(element._computeChangeSize({
-      insertions: 1,
-      deletions: 1,
-    }), 'XS');
-    assert.equal(element._computeChangeSize({
-      insertions: 9,
-      deletions: 1,
-    }), 'S');
-    assert.equal(element._computeChangeSize({
-      insertions: 10,
-      deletions: 200,
-    }), 'M');
-    assert.equal(element._computeChangeSize({
-      insertions: 99,
-      deletions: 900,
-    }), 'L');
-    assert.equal(element._computeChangeSize({
-      insertions: 99,
-      deletions: 999,
-    }), 'XL');
-  });
-
-  test('change params passed to gr-navigation', () => {
-    sinon.stub(GerritNav);
-    const change = {
-      internalHost: 'test-host',
-      project: 'test-repo',
-      topic: 'test-topic',
-      branch: 'test-branch',
-    };
-    element.change = change;
-    flush();
-
-    assert.deepEqual(GerritNav.getUrlForChange.lastCall.args, [change]);
-    assert.deepEqual(GerritNav.getUrlForProjectChanges.lastCall.args,
-        [change.project, true, change.internalHost]);
-    assert.deepEqual(GerritNav.getUrlForBranch.lastCall.args,
-        [change.branch, change.project, undefined, change.internalHost]);
-    assert.deepEqual(GerritNav.getUrlForTopic.lastCall.args,
-        [change.topic, change.internalHost]);
-  });
-
-  test('_computeRepoDisplay', () => {
-    const change = {
-      project: 'a/test/repo',
-      internalHost: 'host',
-    };
-    assert.equal(element._computeRepoDisplay(change), 'host/a/test/repo');
-    assert.equal(element._computeRepoDisplay(change, true),
-        'host/…/test/repo');
-    delete change.internalHost;
-    assert.equal(element._computeRepoDisplay(change), 'a/test/repo');
-    assert.equal(element._computeRepoDisplay(change, true),
-        '…/test/repo');
-  });
-});
-
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
new file mode 100644
index 0000000..ac0b929
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -0,0 +1,577 @@
+/**
+ * @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 {
+  createAccountWithId,
+  createChange,
+} from '../../../test/test-data-generators';
+import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  AccountId,
+  BranchName,
+  ChangeInfo,
+  RepoName,
+  TopicName,
+} from '../../../types/common';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import './gr-change-list-item';
+import {GrChangeListItem, LabelCategory} from './gr-change-list-item';
+
+const basicFixture = fixtureFromElement('gr-change-list-item');
+
+suite('gr-change-list-item tests', () => {
+  const account = createAccountWithId();
+  const change: ChangeInfo = {
+    ...createChange(),
+    internalHost: 'host',
+    project: 'a/test/repo' as RepoName,
+    topic: 'test-topic' as TopicName,
+    branch: 'test-branch' as BranchName,
+  };
+
+  let element: GrChangeListItem;
+
+  setup(() => {
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    element = basicFixture.instantiate();
+  });
+
+  test('_computeLabelCategory', () => {
+    assert.equal(
+      element._computeLabelCategory({...change, labels: {}}, 'Verified'),
+      LabelCategory.NOT_APPLICABLE
+    );
+    assert.equal(
+      element._computeLabelCategory(
+        {...change, labels: {Verified: {approved: account, value: 1}}},
+        'Verified'
+      ),
+      LabelCategory.APPROVED
+    );
+    assert.equal(
+      element._computeLabelCategory(
+        {...change, labels: {Verified: {rejected: account, value: -1}}},
+        'Verified'
+      ),
+      LabelCategory.REJECTED
+    );
+    assert.equal(
+      element._computeLabelCategory(
+        {
+          ...change,
+          labels: {'Code-Review': {approved: account, value: 1}},
+          unresolved_comment_count: 1,
+        },
+        'Code-Review'
+      ),
+      LabelCategory.UNRESOLVED_COMMENTS
+    );
+    assert.equal(
+      element._computeLabelCategory(
+        {...change, labels: {'Code-Review': {value: 1}}},
+        'Code-Review'
+      ),
+      LabelCategory.POSITIVE
+    );
+    assert.equal(
+      element._computeLabelCategory(
+        {...change, labels: {'Code-Review': {value: -1}}},
+        'Code-Review'
+      ),
+      LabelCategory.NEGATIVE
+    );
+    assert.equal(
+      element._computeLabelCategory(
+        {...change, labels: {'Code-Review': {value: -1}}},
+        'Verified'
+      ),
+      LabelCategory.NOT_APPLICABLE
+    );
+  });
+
+  test('_computeLabelClass', () => {
+    assert.equal(
+      element._computeLabelClass({...change, labels: {}}, 'Verified'),
+      'cell label u-gray-background'
+    );
+    assert.equal(
+      element._computeLabelClass(
+        {...change, labels: {Verified: {approved: account, value: 1}}},
+        'Verified'
+      ),
+      'cell label u-green'
+    );
+    assert.equal(
+      element._computeLabelClass(
+        {...change, labels: {Verified: {rejected: account, value: -1}}},
+        'Verified'
+      ),
+      'cell label u-red'
+    );
+    assert.equal(
+      element._computeLabelClass(
+        {...change, labels: {'Code-Review': {value: 1}}},
+        'Code-Review'
+      ),
+      'cell label u-green u-monospace'
+    );
+    assert.equal(
+      element._computeLabelClass(
+        {...change, labels: {'Code-Review': {value: -1}}},
+        'Code-Review'
+      ),
+      'cell label u-monospace u-red'
+    );
+    assert.equal(
+      element._computeLabelClass(
+        {...change, labels: {'Code-Review': {value: -1}}},
+        'Verified'
+      ),
+      'cell label u-gray-background'
+    );
+  });
+
+  test('_computeLabelTitle', () => {
+    assert.equal(
+      element._computeLabelTitle({...change, labels: {}}, 'Verified'),
+      'Label not applicable'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {...change, labels: {Verified: {approved: {name: 'Diffy'}}}},
+        'Verified'
+      ),
+      'Verified by Diffy'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {...change, labels: {Verified: {approved: {name: 'Diffy'}}}},
+        'Code-Review'
+      ),
+      'Label not applicable'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {...change, labels: {Verified: {rejected: {name: 'Diffy'}}}},
+        'Verified'
+      ),
+      'Verified by Diffy'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {'Code-Review': {disliked: {name: 'Diffy'}, value: -1}},
+        },
+        'Code-Review'
+      ),
+      'Code-Review by Diffy'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {'Code-Review': {recommended: {name: 'Diffy'}, value: 1}},
+        },
+        'Code-Review'
+      ),
+      'Code-Review by Diffy'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {
+            'Code-Review': {
+              recommended: {name: 'Diffy'},
+              rejected: {name: 'Admin'},
+            },
+          },
+        },
+        'Code-Review'
+      ),
+      'Code-Review by Admin'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {
+            'Code-Review': {
+              approved: {name: 'Diffy'},
+              rejected: {name: 'Admin'},
+            },
+          },
+        },
+        'Code-Review'
+      ),
+      'Code-Review by Admin'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {
+            'Code-Review': {
+              recommended: {name: 'Diffy'},
+              disliked: {name: 'Admin'},
+              value: -1,
+            },
+          },
+        },
+        'Code-Review'
+      ),
+      'Code-Review by Admin'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {
+            'Code-Review': {
+              approved: {name: 'Diffy'},
+              disliked: {name: 'Admin'},
+              value: -1,
+            },
+          },
+        },
+        'Code-Review'
+      ),
+      'Code-Review by Diffy'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {'Code-Review': {approved: account, value: 1}},
+          unresolved_comment_count: 1,
+        },
+        'Code-Review'
+      ),
+      '1 unresolved comment'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {'Code-Review': {approved: {name: 'Diffy'}, value: 1}},
+          unresolved_comment_count: 1,
+        },
+        'Code-Review'
+      ),
+      '1 unresolved comment,\nCode-Review by Diffy'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {'Code-Review': {approved: account, value: 1}},
+          unresolved_comment_count: 2,
+        },
+        'Code-Review'
+      ),
+      '2 unresolved comments'
+    );
+  });
+
+  test('_computeLabelIcon', () => {
+    assert.equal(
+      element._computeLabelIcon({...change, labels: {}}, 'missingLabel'),
+      ''
+    );
+    assert.equal(
+      element._computeLabelIcon(
+        {...change, labels: {Verified: {approved: account, value: 1}}},
+        'Verified'
+      ),
+      'gr-icons:check'
+    );
+    assert.equal(
+      element._computeLabelIcon(
+        {
+          ...change,
+          labels: {'Code-Review': {approved: account, value: 1}},
+          unresolved_comment_count: 1,
+        },
+        'Code-Review'
+      ),
+      'gr-icons:comment'
+    );
+  });
+
+  test('_computeLabelValue', () => {
+    assert.equal(
+      element._computeLabelValue({...change, labels: {}}, 'Verified'),
+      ''
+    );
+    assert.equal(
+      element._computeLabelValue(
+        {...change, labels: {Verified: {approved: account, value: 1}}},
+        'Verified'
+      ),
+      '✓'
+    );
+    assert.equal(
+      element._computeLabelValue(
+        {...change, labels: {Verified: {value: 1}}},
+        'Verified'
+      ),
+      '+1'
+    );
+    assert.equal(
+      element._computeLabelValue(
+        {...change, labels: {Verified: {value: -1}}},
+        'Verified'
+      ),
+      '-1'
+    );
+    assert.equal(
+      element._computeLabelValue(
+        {...change, labels: {Verified: {approved: account}}},
+        'Verified'
+      ),
+      '✓'
+    );
+    assert.equal(
+      element._computeLabelValue(
+        {...change, labels: {Verified: {rejected: account}}},
+        'Verified'
+      ),
+      '✕'
+    );
+  });
+
+  test('no hidden columns', async () => {
+    element.visibleChangeTableColumns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Repo',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+
+    await flush();
+
+    for (const column of element.columnNames) {
+      const elementClass = '.' + column.toLowerCase();
+      assert.isFalse(
+        queryAndAssert(element, elementClass).hasAttribute('hidden')
+      );
+    }
+  });
+
+  test('repo column hidden', async () => {
+    element.visibleChangeTableColumns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+
+    await flush();
+
+    for (const column of element.columnNames) {
+      const elementClass = '.' + column.toLowerCase();
+      if (column === 'Repo') {
+        assert.isTrue(
+          queryAndAssert(element, elementClass).hasAttribute('hidden')
+        );
+      } else {
+        assert.isFalse(
+          queryAndAssert(element, elementClass).hasAttribute('hidden')
+        );
+      }
+    }
+  });
+
+  function checkComputeReviewers(
+    userId: number | undefined,
+    reviewerIds: number[],
+    reviewerNames: (string | undefined)[],
+    attSetIds: number[],
+    expected: number[]
+  ) {
+    element.account = userId ? {_account_id: userId as AccountId} : null;
+    element.change = {
+      ...change,
+      owner: {
+        _account_id: 99 as AccountId,
+      },
+      reviewers: {
+        REVIEWER: [],
+      },
+      attention_set: {},
+    };
+    for (let i = 0; i < reviewerIds.length; i++) {
+      element.change!.reviewers.REVIEWER!.push({
+        _account_id: reviewerIds[i] as AccountId,
+        name: reviewerNames[i],
+      });
+    }
+    attSetIds.forEach(id => (element.change!.attention_set![id] = {account}));
+
+    const actual = element
+      ._computeReviewers(element.change)
+      .map(r => r._account_id);
+    assert.deepEqual(actual, expected as AccountId[]);
+  }
+
+  test('compute reviewers', () => {
+    checkComputeReviewers(undefined, [], [], [], []);
+    checkComputeReviewers(1, [], [], [], []);
+    checkComputeReviewers(1, [2], ['a'], [], [2]);
+    checkComputeReviewers(1, [2, 3], [undefined, 'a'], [], [2, 3]);
+    checkComputeReviewers(1, [2, 3], ['a', undefined], [], [3, 2]);
+    checkComputeReviewers(1, [99], ['owner'], [], []);
+    checkComputeReviewers(
+      1,
+      [2, 3, 4, 5],
+      ['b', 'a', 'd', 'c'],
+      [3, 4],
+      [3, 4, 2, 5]
+    );
+    checkComputeReviewers(
+      1,
+      [2, 3, 1, 4, 5],
+      ['b', 'a', 'x', 'd', 'c'],
+      [3, 4],
+      [1, 3, 4, 2, 5]
+    );
+  });
+
+  test('random column does not exist', async () => {
+    element.visibleChangeTableColumns = ['Bad'];
+
+    await flush();
+    const elementClass = '.bad';
+    assert.isNotOk(query(element, elementClass));
+  });
+
+  test('assignee only displayed if there is one', async () => {
+    element.change = change;
+    await flush();
+    assert.isNotOk(query(element, '.assignee gr-account-link'));
+    assert.equal(
+      queryAndAssert(element, '.assignee').textContent!.trim(),
+      '--'
+    );
+    element.change = {
+      ...change,
+      assignee: {
+        name: 'test',
+        status: 'test',
+      },
+    };
+    await flush();
+    queryAndAssert(element, '.assignee gr-account-link');
+  });
+
+  test('TShirt sizing tooltip', () => {
+    assert.equal(
+      element._computeSizeTooltip({
+        ...change,
+        insertions: NaN,
+        deletions: NaN,
+      }),
+      'Size unknown'
+    );
+    assert.equal(
+      element._computeSizeTooltip({...change, insertions: 0, deletions: 0}),
+      'Size unknown'
+    );
+    assert.equal(
+      element._computeSizeTooltip({...change, insertions: 1, deletions: 2}),
+      'added 1, removed 2 lines'
+    );
+  });
+
+  test('TShirt sizing', () => {
+    assert.equal(
+      element._computeChangeSize({
+        ...change,
+        insertions: NaN,
+        deletions: NaN,
+      }),
+      null
+    );
+    assert.equal(
+      element._computeChangeSize({...change, insertions: 1, deletions: 1}),
+      'XS'
+    );
+    assert.equal(
+      element._computeChangeSize({...change, insertions: 9, deletions: 1}),
+      'S'
+    );
+    assert.equal(
+      element._computeChangeSize({...change, insertions: 10, deletions: 200}),
+      'M'
+    );
+    assert.equal(
+      element._computeChangeSize({...change, insertions: 99, deletions: 900}),
+      'L'
+    );
+    assert.equal(
+      element._computeChangeSize({...change, insertions: 99, deletions: 999}),
+      'XL'
+    );
+  });
+
+  test('change params passed to gr-navigation', async () => {
+    const navStub = sinon.stub(GerritNav);
+    element.change = change;
+    await flush();
+
+    assert.deepEqual(navStub.getUrlForChange.lastCall.args, [change]);
+    assert.deepEqual(navStub.getUrlForProjectChanges.lastCall.args, [
+      change.project,
+      true,
+      change.internalHost,
+    ]);
+    assert.deepEqual(navStub.getUrlForBranch.lastCall.args, [
+      change.branch,
+      change.project,
+      undefined,
+      change.internalHost,
+    ]);
+    assert.deepEqual(navStub.getUrlForTopic.lastCall.args, [
+      change.topic,
+      change.internalHost,
+    ]);
+  });
+
+  test('_computeRepoDisplay', () => {
+    assert.equal(
+      element._computeRepoDisplay(change, false),
+      'host/a/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');
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 927d32f..8ce00f2 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -16,17 +16,14 @@
  */
 
 import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-change-list/gr-change-list';
 import '../gr-repo-header/gr-repo-header';
 import '../gr-user-header/gr-user-header';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-list-view_html';
 import {page} from '../../../utils/page-wrapper-utils';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
 import {AppElementParams} from '../../gr-app-types';
 import {
@@ -36,17 +33,18 @@
   EmailAddress,
   PreferencesInput,
 } from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {ChangeListViewState} from '../../../types/types';
+import {fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {GerritView} from '../../../services/router/router-model';
 
-const LookupQueryPatterns = {
-  CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
-  CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
-  COMMIT: /[0-9a-f]{40}/,
-};
+const LOOKUP_QUERY_PATTERNS: RegExp[] = [
+  /^\s*i?[0-9a-f]{7,40}\s*$/i, // CHANGE_ID
+  /^\s*[1-9][0-9]*\s*$/g, // CHANGE_NUM
+  /[0-9a-f]{40}/, // COMMIT
+];
 
 const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
 
@@ -56,16 +54,13 @@
 
 export interface GrChangeListView {
   $: {
-    restAPI: RestApiService & Element;
     prevArrow: HTMLAnchorElement;
     nextArrow: HTMLAnchorElement;
   };
 }
 
 @customElement('gr-change-list-view')
-export class GrChangeListView extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrChangeListView extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -112,16 +107,17 @@
   @property({type: String})
   _repo: string | null = null;
 
-  /** @override */
-  created() {
-    super.created();
+  private readonly restApiService = appContext.restApiService;
+
+  constructor() {
+    super();
     this.addEventListener('next-page', () => this._handleNextPage());
     this.addEventListener('previous-page', () => this._handlePreviousPage());
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadPreferences();
   }
 
@@ -143,17 +139,9 @@
 
     // NOTE: This method may be called before attachment. Fire title-change
     // in an async so that attachment to the DOM can take place first.
-    this.async(() =>
-      this.dispatchEvent(
-        new CustomEvent('title-change', {
-          detail: {title: this._query},
-          composed: true,
-          bubbles: true,
-        })
-      )
-    );
+    setTimeout(() => fireTitleChange(this, this._query));
 
-    this.$.restAPI
+    this.restApiService
       .getPreferences()
       .then(prefs => {
         if (!prefs) {
@@ -165,12 +153,8 @@
       .then(changes => {
         changes = changes || [];
         if (this._query && changes.length === 1) {
-          let query: keyof typeof LookupQueryPatterns;
-          for (query in LookupQueryPatterns) {
-            if (
-              hasOwnProperty(LookupQueryPatterns, query) &&
-              this._query.match(LookupQueryPatterns[query])
-            ) {
+          for (const queryPattern of LOOKUP_QUERY_PATTERNS) {
+            if (this._query.match(queryPattern)) {
               // "Back"/"Forward" buttons work correctly only with
               // opt_redirect options
               GerritNav.navigateToChange(
@@ -190,9 +174,9 @@
   }
 
   _loadPreferences() {
-    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+    return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
-        this.$.restAPI.getPreferences().then(preferences => {
+        this.restApiService.getPreferences().then(preferences => {
           this.preferences = preferences;
         });
       } else {
@@ -202,7 +186,7 @@
   }
 
   _getChanges() {
-    return this.$.restAPI.getChanges(
+    return this.restApiService.getChanges(
       this._changesPerPage,
       this._query,
       this._offset
@@ -289,11 +273,14 @@
   }
 
   _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
-    this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+    this.restApiService.saveChangeStarred(
+      e.detail.change._number,
+      e.detail.starred
+    );
   }
 
   _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
-    this.$.restAPI.saveChangeReviewed(
+    this.restApiService.saveChangeReviewed(
       e.detail.change._number,
       e.detail.reviewed
     );
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
index 0e8f843..9914e70 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
@@ -98,5 +98,4 @@
       </a>
     </nav>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
index af3acd8..445254a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
@@ -20,6 +20,7 @@
 import {page} from '../../../utils/page-wrapper-utils.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import 'lodash/lodash.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-change-list-view');
 
@@ -30,14 +31,10 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      getChanges(num, query) {
-        return Promise.resolve([]);
-      },
-      getAccountDetails() { return Promise.resolve({}); },
-      getAccountStatus() { return Promise.resolve({}); },
-    });
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getChanges').returns(Promise.resolve([]));
+    stubRestApi('getAccountDetails').returns(Promise.resolve({}));
+    stubRestApi('getAccountStatus').returns(Promise.resolve({}));
     element = basicFixture.instantiate();
   });
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 3acdaf9..62cfcb4 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -17,13 +17,10 @@
 
 import '../../../styles/gr-change-list-styles';
 import '../../shared/gr-cursor-manager/gr-cursor-manager';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-change-list-item/gr-change-list-item';
 import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-list_html';
 import {appContext} from '../../../services/app-context';
@@ -43,7 +40,6 @@
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {changeIsOpen, isOwner} from '../../../utils/change-util';
 import {customElement, property, observe} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {
   AccountInfo,
@@ -56,6 +52,9 @@
   isAttentionSetEnabled,
 } from '../../../utils/attention-set-util';
 import {CustomKeyboardEvent} from '../../../types/events';
+import {fireEvent} from '../../../utils/event-util';
+import {windowLocationReload} from '../../../utils/dom-util';
+import {ScrollMode} from '../../../constants/constants';
 
 const NUMBER_FIXED_COLUMNS = 3;
 const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
@@ -68,16 +67,11 @@
   results: ChangeInfo[];
 }
 export interface GrChangeList {
-  $: {
-    restAPI: RestApiService & Element;
-    cursor: GrCursorManager;
-  };
+  $: {};
 }
 @customElement('gr-change-list')
 export class GrChangeList extends ChangeTableMixin(
-  KeyboardShortcutMixin(
-    GestureEventListeners(LegacyElementMixin(PolymerElement))
-  )
+  KeyboardShortcutMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -142,11 +136,16 @@
   @property({type: Object})
   preferences?: PreferencesInput;
 
+  @property({type: Boolean})
+  isCursorMoving = false;
+
   @property({type: Object})
   _config?: ServerInfo;
 
   flagsService = appContext.flagsService;
 
+  private readonly restApiService = appContext.restApiService;
+
   keyboardShortcuts() {
     return {
       [Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
@@ -160,23 +159,26 @@
     };
   }
 
-  /** @override */
-  created() {
-    super.created();
+  private cursor = new GrCursorManager();
+
+  constructor() {
+    super();
+    this.cursor.scrollMode = ScrollMode.KEEP_VISIBLE;
+    this.cursor.focusOnMove = true;
     this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
   }
 
   /** @override */
   ready() {
     super.ready();
-    this.$.restAPI.getConfig().then(config => {
+    this.restApiService.getConfig().then(config => {
       this._config = config;
     });
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -186,6 +188,12 @@
       });
   }
 
+  /** @override */
+  disconnectedCallback() {
+    this.cursor.unsetCursor();
+    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
@@ -227,7 +235,9 @@
         preferences && preferences.legacycid_in_change_table
       );
       if (preferences.change_table && preferences.change_table.length > 0) {
-        const prefColumns = this.getVisibleColumns(preferences.change_table);
+        const prefColumns = this.renameProjectToRepoColumn(
+          preferences.change_table
+        );
         this.visibleChangeTableColumns = this.getEnabledColumns(
           prefColumns,
           config,
@@ -349,7 +359,13 @@
     return idx === selectedIndex;
   }
 
-  _computeTabIndex(sectionIndex: number, index: number, selectedIndex: number) {
+  _computeTabIndex(
+    sectionIndex: number,
+    index: number,
+    selectedIndex: number,
+    isCursorMoving: boolean
+  ) {
+    if (isCursorMoving) return 0;
     return this._computeItemSelected(sectionIndex, index, selectedIndex)
       ? 0
       : undefined;
@@ -392,7 +408,10 @@
     }
 
     e.preventDefault();
-    this.$.cursor.next();
+    this.isCursorMoving = true;
+    this.cursor.next();
+    this.isCursorMoving = false;
+    this.selectedIndex = this.cursor.index;
   }
 
   _prevChange(e: CustomKeyboardEvent) {
@@ -401,7 +420,10 @@
     }
 
     e.preventDefault();
-    this.$.cursor.previous();
+    this.isCursorMoving = true;
+    this.cursor.previous();
+    this.isCursorMoving = false;
+    this.selectedIndex = this.cursor.index;
   }
 
   _openChange(e: CustomKeyboardEvent) {
@@ -424,12 +446,7 @@
     }
 
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('next-page', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'next-page');
   }
 
   _prevPage(e: CustomKeyboardEvent) {
@@ -479,7 +496,7 @@
   }
 
   _reloadWindow() {
-    window.location.reload();
+    windowLocationReload();
   }
 
   _toggleChangeStar(e: CustomKeyboardEvent) {
@@ -519,8 +536,9 @@
   _sectionsChanged() {
     // Flush DOM operations so that the list item elements will be loaded.
     afterNextRender(this, () => {
-      this.$.cursor.stops = this._getListItems();
-      this.$.cursor.moveToStart();
+      this.cursor.stops = this._getListItems();
+      this.cursor.moveToStart();
+      if (this.selectedIndex) this.cursor.setCursorAtIndex(this.selectedIndex);
     });
   }
 
@@ -533,6 +551,11 @@
   _isEmpty(section: DashboardSection) {
     return !section.results?.length;
   }
+
+  _computeAriaLabel(change?: ChangeInfo, sectionName?: string) {
+    if (!change) return '';
+    return change.subject + (sectionName ? `, section: ${sectionName}` : '');
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
index 06957d9..317d27f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
@@ -82,7 +82,11 @@
         <template is="dom-if" if="[[_isEmpty(changeSection)]]">
           <tr class="noChanges">
             <td aria-hidden="true" class="leftPadding"></td>
-            <td aria-hidden="true" class="star" hidden></td>
+            <td
+              aria-hidden="[[!showStar]]"
+              class="star"
+              hidden$="[[!showStar]]"
+            ></td>
             <td
               class="cell"
               colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
@@ -117,9 +121,7 @@
               items="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
               as="item"
             >
-              <td class$="[[_lowerCase(item)]]">
-                [[item]]
-              </td>
+              <td class$="[[_lowerCase(item)]]">[[item]]</td>
             </template>
             <template is="dom-repeat" items="[[labelNames]]" as="labelName">
               <td class="label" title$="[[labelName]]">
@@ -150,18 +152,12 @@
             visible-change-table-columns="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
             show-number="[[showNumber]]"
             show-star="[[showStar]]"
-            tabindex$="[[_computeTabIndex(sectionIndex, index, selectedIndex)]]"
+            tabindex$="[[_computeTabIndex(sectionIndex, index, selectedIndex, isCursorMoving)]]"
             label-names="[[labelNames]]"
+            aria-label$="[[_computeAriaLabel(change, changeSection.name)]]"
           ></gr-change-list-item>
         </template>
       </tbody>
     </template>
   </table>
-  <gr-cursor-manager
-    id="cursor"
-    index="{{selectedIndex}}"
-    scroll-mode="keep-visible"
-    focus-on-move=""
-  ></gr-cursor-manager>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
index 35f3aeb..0e2668d 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
@@ -18,11 +18,10 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icons/gr-icons';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement} from '@polymer/decorators';
 import {htmlTemplate} from './gr-create-change-help_html';
+import {fireEvent} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -31,9 +30,7 @@
 }
 
 @customElement('gr-create-change-help')
-class GrCreateChangeHelp extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrCreateChangeHelp extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -43,8 +40,6 @@
    */
   _handleCreateTap(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('create-tap', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'create-tap');
   }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
index c2f97a6..95a67af 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
@@ -21,10 +21,10 @@
     :host {
       display: block;
     }
-    #graphic,
-    #help {
+    #graphic {
       display: inline-block;
       margin: var(--spacing-m);
+      margin-left: 0;
     }
     #graphic #circle {
       align-items: center;
@@ -36,7 +36,7 @@
       width: 10em;
     }
     #graphic iron-icon {
-      color: #9e9e9e;
+      color: var(--gray-foreground);
       height: 5em;
       width: 5em;
     }
@@ -45,6 +45,8 @@
       text-align: center;
     }
     #help {
+      display: inline-block;
+      margin: var(--spacing-m);
       padding-top: var(--spacing-xl);
       vertical-align: top;
     }
@@ -62,9 +64,7 @@
     <div id="circle">
       <iron-icon id="icon" icon="gr-icons:zeroState"></iron-icon>
     </div>
-    <p>
-      No outgoing changes yet
-    </p>
+    <p>No outgoing changes yet</p>
   </div>
   <div id="help">
     <h2 class="heading-3">Push your first change for code review</h2>
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 1ee8cb5..f2f767a 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,8 +19,6 @@
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-shell-command/gr-shell-command';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-create-commands-dialog_html';
@@ -44,9 +42,7 @@
 }
 
 @customElement('gr-create-commands-dialog')
-export class GrCreateCommandsDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCreateCommandsDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
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
index 9031738..d2f35d6 100644
--- 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
@@ -37,26 +37,18 @@
       confirm-on-enter=""
       on-confirm="_handleClose"
     >
-      <div class="header" slot="header">
-        Create change commands
-      </div>
+      <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>
+            <p>Make the changes to the files on your machine</p>
           </li>
           <li>
-            <p>
-              If you are making a new commit use
-            </p>
+            <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>
+            <p>Or to amend an existing commit use</p>
             <gr-shell-command
               command="[[_amendExistingCommitCommand]]"
             ></gr-shell-command>
@@ -66,9 +58,7 @@
             </p>
           </li>
           <li>
-            <p>
-              Push the change for code review
-            </p>
+            <p>Push the change for code review</p>
             <gr-shell-command command="[[_pushCommand]]"></gr-shell-command>
           </li>
           <li>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
index e53f68b..ff9666b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
@@ -18,8 +18,6 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-repo-branch-picker/gr-repo-branch-picker';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-create-destination-dialog_html';
 import {customElement, property} from '@polymer/decorators';
@@ -44,9 +42,7 @@
 }
 
 @customElement('gr-create-destination-dialog')
-export class GrCreateDestinationDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCreateDestinationDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.ts
index 9155d9a..0aed75b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.ts
@@ -25,17 +25,13 @@
       on-cancel="_handleClose"
       disabled="[[!_repoAndBranchSelected]]"
     >
-      <div class="header" slot="header">
-        Create change
-      </div>
+      <div class="header" slot="header">Create change</div>
       <div class="main" slot="main">
         <gr-repo-branch-picker
           repo="{{_repo}}"
           branch="{{_branch}}"
         ></gr-repo-branch-picker>
-        <p>
-          If you haven't done so, you will need to clone the repository.
-        </p>
+        <p>If you haven't done so, you will need to clone the repository.</p>
       </div>
     </gr-dialog>
   </gr-overlay>
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index 57d3dd9..f9dee5f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -19,18 +19,14 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-create-commands-dialog/gr-create-commands-dialog';
 import '../gr-create-change-help/gr-create-change-help';
 import '../gr-create-destination-dialog/gr-create-destination-dialog';
 import '../gr-user-header/gr-user-header';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-dashboard-view_html';
 import {
   GerritNav,
-  GerritView,
   UserDashboard,
   YOUR_TURN,
 } from '../../core/gr-navigation/gr-navigation';
@@ -47,7 +43,6 @@
   RepoName,
 } from '../../../types/common';
 import {AppElementDashboardParams, AppElementParams} from '../../gr-app-types';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrCreateCommandsDialog} from '../gr-create-commands-dialog/gr-create-commands-dialog';
 import {
@@ -58,12 +53,14 @@
 import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {DashboardViewState} from '../../../types/types';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {GerritView} from '../../../services/router/router-model';
 
-const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
+const PROJECT_PLACEHOLDER_PATTERN = /\${project}/g;
+const RELOAD_DASHBOARD_INTERVAL_MS = 10 * 1000;
 
 export interface GrDashboardView {
   $: {
-    restAPI: RestApiService & Element;
     confirmDeleteDialog: GrDialog;
     commandsDialog: GrCreateCommandsDialog;
     destinationDialog: GrCreateDestinationDialog;
@@ -80,9 +77,7 @@
 }
 
 @customElement('gr-dashboard-view')
-export class GrDashboardView extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDashboardView extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -117,26 +112,44 @@
   @property({type: Boolean})
   _showNewUserHelp = false;
 
+  @property({type: Number})
+  _selectedChangeIndex?: number;
+
   private reporting = appContext.reportingService;
 
+  private readonly restApiService = appContext.restApiService;
+
+  private lastVisibleTimestampMs = 0;
+
   constructor() {
     super();
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadPreferences();
     this.addEventListener('reload', e => {
       e.stopPropagation();
-      this._reload();
+      this._reload(this.params);
+    });
+    document.addEventListener('visibilitychange', () => {
+      if (document.visibilityState === 'visible') {
+        if (
+          Date.now() - this.lastVisibleTimestampMs >
+          RELOAD_DASHBOARD_INTERVAL_MS
+        )
+          this._reload(this.params);
+      } else {
+        this.lastVisibleTimestampMs = Date.now();
+      }
     });
   }
 
   _loadPreferences() {
-    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+    return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
-        this.$.restAPI.getPreferences().then(preferences => {
+        this.restApiService.getPreferences().then(preferences => {
           this.preferences = preferences;
         });
       } else {
@@ -150,15 +163,9 @@
     dashboard: DashboardId
   ): Promise<UserDashboard | undefined> {
     const errFn = (response?: Response | null) => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
-    return this.$.restAPI
+    return this.restApiService
       .getDashboard(project, dashboard, errFn)
       .then(response => {
         if (!response) {
@@ -191,12 +198,21 @@
     return params.view === GerritView.DASHBOARD;
   }
 
+  @observe('_selectedChangeIndex')
+  _selectedChangeIndexChanged(selectedChangeIndex: number) {
+    if (!this.params || !this._isViewActive(this.params)) return;
+    if (!this.viewState) throw new Error('view state undefined');
+    if (!this.params.user) throw new Error('user for dashboard is undefined');
+    this.viewState[this.params.user] = selectedChangeIndex;
+  }
+
   @observe('params.*')
   _paramsChanged(
     paramsChangeRecord: ElementPropertyDeepChange<GrDashboardView, 'params'>
   ) {
     const params = paramsChangeRecord.base;
-
+    if (params && this._isViewActive(params) && params.user && this.viewState)
+      this._selectedChangeIndex = this.viewState[params.user] || 0;
     return this._reload(params);
   }
 
@@ -211,7 +227,7 @@
     const {project, dashboard, title, user, sections} = params;
     const dashboardPromise: Promise<UserDashboard | undefined> = project
       ? this._getProjectDashboard(project, dashboard)
-      : this.$.restAPI
+      : this.restApiService
           .getConfig()
           .then(config =>
             Promise.resolve(
@@ -224,17 +240,13 @@
             )
           );
 
-    const checkForNewUser = !project && user === 'self';
+    // Checking `this.account` to make sure that the user is logged in.
+    // Otherwise sending a query for 'owner:self' will result in an error.
+    const checkForNewUser = !project && !!this.account && user === 'self';
     return dashboardPromise
       .then(res => {
         if (res && res.title) {
-          this.dispatchEvent(
-            new CustomEvent('title-change', {
-              detail: {title: res.title},
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fireTitleChange(this, res.title);
         }
         return this._fetchDashboardChanges(res, checkForNewUser);
       })
@@ -243,15 +255,7 @@
         this.reporting.dashboardDisplayed();
       })
       .catch(err => {
-        this.dispatchEvent(
-          new CustomEvent('title-change', {
-            detail: {
-              title: title || this._computeTitle(user),
-            },
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireTitleChange(this, title || this._computeTitle(user));
         console.warn(err);
       })
       .then(() => {
@@ -289,7 +293,7 @@
       }
     }
 
-    return this.$.restAPI.getChanges(undefined, queries).then(changes => {
+    return this.restApiService.getChanges(undefined, queries).then(changes => {
       if (!changes) {
         throw new Error('getChanges returns undefined');
       }
@@ -368,14 +372,43 @@
   }
 
   _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
-    this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+    this.restApiService.saveChangeStarred(
+      e.detail.change._number,
+      e.detail.starred
+    );
+    // When a change is updated the same change may appear elsewhere in the
+    // dashboard (but is not the same object), so we must update other
+    // occurrences of the same change.
+    this._results?.forEach((dashboardChange, dashboardIndex) =>
+      dashboardChange.results.forEach((change, changeIndex) => {
+        if (change.id === e.detail.change.id) {
+          this.set(
+            `_results.${dashboardIndex}.results.${changeIndex}.starred`,
+            e.detail.starred
+          );
+        }
+      })
+    );
   }
 
   _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
-    this.$.restAPI.saveChangeReviewed(
+    this.restApiService.saveChangeReviewed(
       e.detail.change._number,
       e.detail.reviewed
     );
+    // When a change is updated the same change may appear elsewhere in the
+    // dashboard (but is not the same object), so we must update other
+    // occurrences of the same change.
+    this._results?.forEach((dashboardChange, dashboardIndex) =>
+      dashboardChange.results.forEach((change, changeIndex) => {
+        if (change.id === e.detail.change.id) {
+          this.set(
+            `_results.${dashboardIndex}.results.${changeIndex}.reviewed`,
+            e.detail.reviewed
+          );
+        }
+      })
+    );
   }
 
   /**
@@ -419,7 +452,7 @@
 
   _handleConfirmDelete() {
     this.$.confirmDeleteDialog.disabled = true;
-    return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
+    return this.restApiService.deleteDraftComments('-is:open').then(() => {
       this._closeConfirmDeleteOverlay();
       this._reload(this.params);
     });
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
index 6dae176..fd23970 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
@@ -50,9 +50,6 @@
     #emptyOutgoing {
       display: block;
     }
-    #emptyYourTurn {
-      text-align: center;
-    }
     @media only screen and (max-width: 50em) {
       .loading {
         padding: 0 var(--spacing-l);
@@ -84,7 +81,7 @@
       show-reviewed-state=""
       account="[[account]]"
       preferences="[[preferences]]"
-      selected-index="{{viewState.selectedChangeIndex}}"
+      selected-index="{{_selectedChangeIndex}}"
       sections="[[_results]]"
       on-toggle-star="_handleToggleStar"
       on-toggle-reviewed="_handleToggleReviewed"
@@ -95,12 +92,10 @@
             on-create-tap="_handleCreateChangeTap"
           ></gr-create-change-help>
         </template>
-        <template is="dom-if" if="[[!_showNewUserHelp]]">
-          No changes
-        </template>
+        <template is="dom-if" if="[[!_showNewUserHelp]]"> No changes </template>
       </div>
       <div id="emptyYourTurn" slot="empty-your-turn">
-        <span>&#x1f389; No changes need your attention &#x1f389;</span>
+        <span>No changes need your attention &nbsp;&#x1f389;</span>
       </div>
     </gr-change-list>
   </div>
@@ -111,9 +106,7 @@
       on-confirm="_handleConfirmDelete"
       on-cancel="_closeConfirmDeleteOverlay"
     >
-      <div class="header" slot="header">
-        Delete comments
-      </div>
+      <div class="header" slot="header">Delete comments</div>
       <div class="main" slot="main">
         Are you sure you want to delete all your draft comments in closed
         changes? This action cannot be undone.
@@ -125,5 +118,4 @@
     on-confirm="_handleDestinationConfirm"
   ></gr-create-destination-dialog>
   <gr-create-commands-dialog id="commandsDialog"></gr-create-commands-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
index 44f203d..165306e 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
@@ -17,10 +17,12 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-dashboard-view.js';
-import {isHidden} from '../../../test/test-utils.js';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritView} from '../../../services/router/router-model.js';
 import {changeIsOpen} from '../../../utils/change-util.js';
 import {ChangeStatus} from '../../../constants/constants.js';
+import {createAccountWithId} from '../../../test/test-data-generators.js';
+import {addListenerForTest, stubRestApi, isHidden} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-dashboard-view');
 
@@ -31,16 +33,14 @@
   let getChangesStub;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      getAccountDetails() { return Promise.resolve({}); },
-      getAccountStatus() { return Promise.resolve(false); },
-    });
-    element = basicFixture.instantiate();
-
-    getChangesStub = sinon.stub(element.$.restAPI, 'getChanges').callsFake(
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getAccountDetails').returns(Promise.resolve({}));
+    stubRestApi('getAccountStatus').returns(Promise.resolve(false));
+    getChangesStub= stubRestApi('getChanges').callsFake(
         (_, qs) => Promise.resolve(qs.map(() => [])));
 
+    element = basicFixture.instantiate();
+
     let resolver;
     paramsChangedPromise = new Promise(resolve => {
       resolver = resolve;
@@ -123,15 +123,14 @@
       const deleteDraftCommentsPromise = new Promise(resolve => {
         deleteDraftCommentsPromiseResolver = resolve;
       });
-      sinon.stub(element.$.restAPI, 'deleteDraftComments')
+      const deleteStub = stubRestApi('deleteDraftComments')
           .returns(deleteDraftCommentsPromise);
 
       // Open confirmation dialog and tap confirm button.
       await element.$.confirmDeleteOverlay.open();
       MockInteractions.tap(element.$.confirmDeleteDialog.$.confirm);
       flush();
-      assert.isTrue(element.$.restAPI.deleteDraftComments
-          .calledWithExactly('-is:open'));
+      assert.isTrue(deleteStub.calledWithExactly('-is:open'));
       assert.isTrue(element.$.confirmDeleteDialog.disabled);
       assert.equal(element._reload.callCount, 0);
 
@@ -201,9 +200,23 @@
         user: 'self',
       };
       return paramsChangedPromise.then(() => {
-        assert.isTrue(
-            getChangesStub.calledWith(undefined,
-                ['1', '2', 'owner:self limit:1']));
+        assert.isTrue(getChangesStub.calledWith(undefined, ['1', '2']));
+      });
+    });
+
+    test('viewing dashboard when logged in includes owner:self query', () => {
+      element.account = createAccountWithId(1);
+      element.params = {
+        view: GerritNav.View.DASHBOARD,
+        sections: [
+          {query: '1'},
+          {query: '2', selfOnly: true},
+        ],
+        user: 'self',
+      };
+      return paramsChangedPromise.then(() => {
+        assert.isTrue(getChangesStub.calledWith(undefined,
+            ['1', '2', 'owner:self limit:1']));
       });
     });
 
@@ -239,7 +252,7 @@
 
   suite('_getProjectDashboard', () => {
     test('dashboard with foreach', () => {
-      sinon.stub(element.$.restAPI, 'getDashboard')
+      stubRestApi('getDashboard')
           .callsFake( () => Promise.resolve({
             title: 'title',
             foreach: 'foreach for ${project}',
@@ -265,7 +278,7 @@
     });
 
     test('dashboard without foreach', () => {
-      sinon.stub(element.$.restAPI, 'getDashboard').callsFake(
+      stubRestApi('getDashboard').callsFake(
           () => Promise.resolve({
             title: 'title',
             sections: [
@@ -293,7 +306,7 @@
       {name: 'test2', query: 'test2', hideIfEmpty: true},
     ];
     getChangesStub.restore();
-    sinon.stub(element.$.restAPI, 'getChanges')
+    stubRestApi('getChanges')
         .returns(Promise.resolve([[], ['nonempty']]));
 
     return element._fetchDashboardChanges({sections}, false).then(() => {
@@ -308,7 +321,7 @@
       {name: 'test2', query: 'test2'},
     ];
     getChangesStub.restore();
-    sinon.stub(element.$.restAPI, 'getChanges')
+    stubRestApi('getChanges')
         .returns(Promise.resolve([[], []]));
 
     return element._fetchDashboardChanges({sections}, false).then(() => {
@@ -318,6 +331,56 @@
     });
   });
 
+  test('toggling star will update change everywhere', () => {
+    // It is important that the same change is represented by multiple objects
+    // and all are updated.
+    const change = {id: '5', starred: false};
+    const sameChange = {id: '5', starred: false};
+    const differentChange = {id: '4', starred: false};
+    element._results = [
+      {query: 'has:draft', results: [change]},
+      {query: 'is:open', results: [sameChange, differentChange]},
+    ];
+
+    element._handleToggleStar(
+        new CustomEvent('toggle-star', {
+          detail: {
+            change,
+            starred: true,
+          },
+        })
+    );
+
+    assert.isTrue(change.starred);
+    assert.isTrue(sameChange.starred);
+    assert.isFalse(differentChange.starred);
+  });
+
+  test('toggling reviewed will update change everywhere', () => {
+    // It is important that the same change is represented by multiple objects
+    // and all are updated.
+    const change = {id: '5', reviewed: false};
+    const sameChange = {id: '5', reviewed: false};
+    const differentChange = {id: '4', reviewed: false};
+    element._results = [
+      {query: 'has:draft', results: [change]},
+      {query: 'is:open', results: [sameChange, differentChange]},
+    ];
+
+    element._handleToggleReviewed(
+        new CustomEvent('toggle-reviewed', {
+          detail: {
+            change,
+            reviewed: true,
+          },
+        })
+    );
+
+    assert.isTrue(change.reviewed);
+    assert.isTrue(sameChange.reviewed);
+    assert.isFalse(differentChange.reviewed);
+  });
+
   test('_showNewUserHelp', () => {
     element._loading = false;
     element._showNewUserHelp = false;
@@ -359,11 +422,11 @@
 
   test('404 page', done => {
     const response = {status: 404};
-    sinon.stub(element.$.restAPI, 'getDashboard').callsFake(
+    stubRestApi('getDashboard').callsFake(
         async (project, dashboard, errFn) => {
           errFn(response);
         });
-    element.addEventListener('page-error', e => {
+    addListenerForTest(document, 'page-error', e => {
       assert.strictEqual(e.detail.response, response);
       paramsChangedPromise.then(done);
     });
@@ -374,15 +437,39 @@
     };
   });
 
-  test('params change triggers dashboardDisplayed()', () => {
+  test('params change triggers dashboardDisplayed()', async () => {
+    stubRestApi('getDashboard').returns(Promise.resolve({
+      title: 'title',
+      sections: [],
+    }));
     sinon.stub(element.reporting, 'dashboardDisplayed');
     element.params = {
       view: GerritNav.View.DASHBOARD,
       project: 'project',
       dashboard: 'dashboard',
     };
-    return paramsChangedPromise.then(() => {
-      assert.isTrue(element.reporting.dashboardDisplayed.calledOnce);
+    await paramsChangedPromise;
+    assert.isTrue(element.reporting.dashboardDisplayed.calledOnce);
+  });
+
+  test('selectedChangeIndex is derived from the params', () => {
+    stubRestApi('getDashboard').returns(Promise.resolve({
+      title: 'title',
+      sections: [],
+    }));
+    element.viewState = {
+      101001: 23,
+    };
+    element.params = {
+      view: GerritNav.View.DASHBOARD,
+      project: 'project',
+      dashboard: 'dashboard',
+      user: '101001',
+    };
+    flush();
+    sinon.stub(element.reporting, 'dashboardDisplayed');
+    paramsChangedPromise.then(() => {
+      assert.equal(element._selectedChangeIndex, 23);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
index e501cfd..6d7b5b1 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
@@ -18,20 +18,16 @@
 import '../../../styles/dashboard-header-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 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';
 
-/** @extends PolymerElement */
 @customElement('gr-repo-header')
-class GrRepoHeader extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrRepoHeader extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -42,12 +38,23 @@
   @property({type: String})
   _repoUrl: string | null = null;
 
+  @property({type: Array})
+  _webLinks: WebLinkInfo[] = [];
+
+  private readonly restApiService = appContext.restApiService;
+
   _repoChanged(repoName: RepoName) {
     if (!repoName) {
       this._repoUrl = null;
       return;
     }
+
     this._repoUrl = GerritNav.getUrlForRepo(repoName);
+
+    this.restApiService.getRepo(repoName).then(repo => {
+      if (!repo?.web_links) return;
+      this._webLinks = repo.web_links;
+    });
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
index d1221d15..0dbec1c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
@@ -18,17 +18,28 @@
 
 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>
+    <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>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
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
index 4f93d54..f877e97 100644
--- 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
@@ -18,6 +18,7 @@
 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');
 
@@ -36,5 +37,24 @@
     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-user-header/gr-user-header.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
index 055c82c..5e4c361 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
@@ -20,28 +20,17 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-avatar/gr-avatar';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/dashboard-header-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 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 {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {AccountDetailInfo, AccountId} from '../../../types/common';
 import {getDisplayName} from '../../../utils/display-name-util';
-
-export interface GrUserHeader {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
 
 @customElement('gr-user-header')
-export class GrUserHeader extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrUserHeader extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -61,6 +50,8 @@
   @property({type: String})
   _status = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   _accountChanged(userId?: AccountId) {
     if (!userId) {
       this._accountDetails = null;
@@ -68,7 +59,7 @@
       return;
     }
 
-    this.$.restAPI.getAccountDetails(userId).then(details => {
+    this.restApiService.getAccountDetails(userId).then(details => {
       this._accountDetails = details ?? null;
       this._status = details?.status ?? '';
     });
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
index 002a4ba..1924a66 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
@@ -33,9 +33,7 @@
     aria-label="Account avatar"
   ></gr-avatar>
   <div class="info">
-    <h1 class="heading-1">
-      [[_computeHeading(_accountDetails)]]
-    </h1>
+    <h1 class="heading-1">[[_computeHeading(_accountDetails)]]</h1>
     <hr />
     <div class$="status [[_computeStatusClass(_status)]]">
       <span>Status:</span> [[_status]]
@@ -66,5 +64,4 @@
       <a href$="[[_computeDashboardUrl(_accountDetails)]]">View dashboard</a>
     </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
index a808f3c..4ec799f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-user-header.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-user-header');
 
@@ -28,7 +29,7 @@
   });
 
   test('loads and clears account info', done => {
-    sinon.stub(element.$.restAPI, 'getAccountDetails')
+    stubRestApi('getAccountDetails')
         .returns(Promise.resolve({
           name: 'foo',
           email: 'bar',
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 3f6dd23..e9a5a46 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -19,9 +19,7 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
 import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
 import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
@@ -32,19 +30,15 @@
 import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-actions_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {appContext} from '../../../services/app-context';
-import {
-  fetchChangeUpdates,
-  patchNumEquals,
-} from '../../../utils/patch-set-util';
+import {fetchChangeUpdates, CURRENT} from '../../../utils/patch-set-util';
 import {
   changeIsOpen,
+  isOwner,
   ListChangesOption,
   listChangesOptionsToHex,
 } from '../../../utils/change-util';
@@ -54,16 +48,10 @@
   HttpMethod,
   NotifyType,
 } from '../../../constants/constants';
-import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
+import {EventType as PluginEventType, TargetElement} from '../../../api/plugin';
 import {customElement, observe, property} from '@polymer/decorators';
-import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
 import {
-  ActionPriority,
-  ActionType,
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {
+  AccountInfo,
   ActionInfo,
   ActionNameToActionInfoMap,
   BranchName,
@@ -104,12 +92,25 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {
-  ChangeActions,
   GrChangeActionsElement,
-  PrimaryActionKey,
-  RevisionActions,
   UIActionInfo,
 } from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
+import {fireAlert} from '../../../utils/event-util';
+import {
+  CODE_REVIEW,
+  getApprovalInfo,
+  getVotingRange,
+} from '../../../utils/label-util';
+import {CommentThread} from '../../../utils/comment-util';
+import {ShowAlertEventDetail} from '../../../types/events';
+import {
+  ActionPriority,
+  ActionType,
+  ChangeActions,
+  PrimaryActionKey,
+  RevisionActions,
+} from '../../../api/change-actions';
+import {ErrorCallback} from '../../../api/rest';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -170,7 +171,7 @@
   method: HttpMethod.POST,
 };
 
-function isQuckApproveAction(
+function isQuickApproveAction(
   action: UIActionInfo
 ): action is QuickApproveUIActionInfo {
   return (action as QuickApproveUIActionInfo).key === QUICK_APPROVE_ACTION.key;
@@ -312,12 +313,11 @@
 
 interface ChangeActionDialog extends HTMLElement {
   resetFocus?(): void;
+  init?(): void;
 }
 
 export interface GrChangeActions {
   $: {
-    jsAPI: GrJsApiInterface;
-    restAPI: RestApiService & Element;
     mainContent: Element;
     overlay: GrOverlay;
     confirmRebase: GrConfirmRebaseDialog;
@@ -337,7 +337,7 @@
 
 @customElement('gr-change-actions')
 export class GrChangeActions
-  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+  extends PolymerElement
   implements GrChangeActionsElement {
   static get template() {
     return htmlTemplate;
@@ -378,6 +378,8 @@
 
   reporting = appContext.reportingService;
 
+  private readonly jsAPI = appContext.jsApiService;
+
   @property({type: Object})
   change?: ChangeViewChangeInfo;
 
@@ -399,6 +401,9 @@
   @property({type: Boolean})
   _hideQuickApproveAction = false;
 
+  @property({type: Object})
+  account?: AccountInfo;
+
   @property({type: String})
   changeNum?: NumericChangeId;
 
@@ -435,6 +440,9 @@
   @property({type: String})
   _actionLoadingMessage = '';
 
+  @property({type: Array})
+  commentThreads: CommentThread[] = [];
+
   @property({
     type: Array,
     computed:
@@ -543,9 +551,10 @@
   @property({type: Object})
   _config?: ServerInfo;
 
-  /** @override */
-  created() {
-    super.created();
+  private readonly restApiService = appContext.restApiService;
+
+  constructor() {
+    super();
     this.addEventListener('fullscreen-overlay-opened', () =>
       this._handleHideBackgroundContent()
     );
@@ -557,8 +566,8 @@
   /** @override */
   ready() {
     super.ready();
-    this.$.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
-    this.$.restAPI.getConfig().then(config => {
+    this.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
+    this.restApiService.getConfig().then(config => {
       this._config = config;
     });
     this._handleLoadingComplete();
@@ -594,7 +603,7 @@
     const change = this.change;
 
     this._loading = true;
-    return this.$.restAPI
+    return this.restApiService
       .getChangeRevisionActions(this.changeNum, this.latestPatchNum)
       .then(revisionActions => {
         if (!revisionActions) {
@@ -609,13 +618,7 @@
         this._handleLoadingComplete();
       })
       .catch(err => {
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: ERR_REVISION_ACTIONS},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireAlert(this, ERR_REVISION_ACTIONS);
         this._loading = false;
         throw err;
       });
@@ -631,7 +634,7 @@
     change: ChangeInfo;
     revisionActions: ActionNameToActionInfoMap;
   }) {
-    this.$.jsAPI.handleEvent(EventType.SHOW_REVISION_ACTIONS, detail);
+    this.jsAPI.handleEvent(PluginEventType.SHOW_REVISION_ACTIONS, detail);
   }
 
   @observe('change')
@@ -889,6 +892,7 @@
       if (editMode && !editPatchsetLoaded) {
         if (!actions.stopEdit) {
           this.set('actions.stopEdit', STOP_EDIT);
+          fireAlert(this, 'Change is in edit mode');
         }
       } else {
         this._deleteAndNotify('stopEdit');
@@ -926,20 +930,24 @@
     if (!this.change || !this.change.labels || !this.change.permitted_labels) {
       return null;
     }
+    if (this.change && this.change.status === ChangeStatus.MERGED) {
+      return null;
+    }
     let result;
-    for (const label in this.change.labels) {
+    for (const [label, labelInfo] of Object.entries(this.change.labels)) {
       if (!(label in this.change.permitted_labels)) {
         continue;
       }
       if (this.change.permitted_labels[label].length === 0) {
         continue;
       }
-      const status = this._getLabelStatus(this.change.labels[label]);
+      const status = this._getLabelStatus(labelInfo);
       if (status === LabelStatus.NEED) {
         if (result) {
-          // More than one label is missing, so it's unclear which to quick
-          // approve, return null;
-          return null;
+          // More than one label is missing, so check if Code Review can be
+          // given
+          result = null;
+          break;
         }
         result = label;
       } else if (
@@ -949,18 +957,39 @@
         return null;
       }
     }
+    // Allow the user to use quick approve to vote the max score on code review
+    // even if it is already granted by someone else. Does not apply if the
+    // user owns the change or has already granted the max score themselves.
+    const codeReviewLabel = this.change.labels[CODE_REVIEW];
+    const codeReviewPermittedValues = this.change.permitted_labels[CODE_REVIEW];
+    if (
+      !result &&
+      codeReviewLabel &&
+      codeReviewPermittedValues &&
+      this.account?._account_id &&
+      isDetailedLabelInfo(codeReviewLabel) &&
+      this._getLabelStatus(codeReviewLabel) === LabelStatus.OK &&
+      !isOwner(this.change, this.account) &&
+      getApprovalInfo(codeReviewLabel, this.account)?.value !==
+        getVotingRange(codeReviewLabel)?.max
+    ) {
+      result = CODE_REVIEW;
+    }
+
     if (result) {
-      const score = this.change.permitted_labels[result].slice(-1)[0];
       const labelInfo = this.change.labels[result];
       if (!isDetailedLabelInfo(labelInfo)) {
         return null;
       }
-      const maxScore = Object.keys(labelInfo.values).slice(-1)[0];
-      if (score === maxScore) {
+      const permittedValues = this.change.permitted_labels[result];
+      const usersMaxPermittedScore =
+        permittedValues[permittedValues.length - 1];
+      const maxScoreForLabel = getVotingRange(labelInfo)?.max;
+      if (Number(usersMaxPermittedScore) === maxScoreForLabel) {
         // Allow quick approve only for maximal score.
         return {
           label: result,
-          score,
+          score: usersMaxPermittedScore,
         };
       }
     }
@@ -972,7 +1001,7 @@
       throw new Error('_topLevelSecondaryActions must be set');
     }
     this._topLevelSecondaryActions = this._topLevelSecondaryActions.filter(
-      sa => !isQuckApproveAction(sa)
+      sa => !isQuickApproveAction(sa)
     );
     this._hideQuickApproveAction = true;
   }
@@ -1074,7 +1103,7 @@
     if (!this.changeNum) {
       return;
     }
-    this.$.restAPI
+    this.restApiService
       .getChangeActionURL(this.changeNum, patchNum, '/' + action.__key)
       .then(url => (action.__url = url));
   }
@@ -1113,7 +1142,7 @@
     if (!this.change) {
       return false;
     }
-    return this.$.jsAPI.canSubmitChange(
+    return this.jsAPI.canSubmitChange(
       this.change,
       this._getRevision(this.change, this.latestPatchNum)
     );
@@ -1121,7 +1150,7 @@
 
   _getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
     for (const rev of Object.values(change.revisions)) {
-      if (patchNumEquals(rev._number, patchNum)) {
+      if (rev._number === patchNum) {
         return rev;
       }
     }
@@ -1136,9 +1165,9 @@
     /* A chromium plugin expects that the modifyRevertMsg hook will only
     be called after the revert button is pressed, hence we populate the
     revert dialog after revert button is pressed. */
-    this.$.restAPI.getChanges(0, query).then(changes => {
+    this.restApiService.getChanges(0, query).then(changes => {
       if (!changes) {
-        console.error('changes is undefined');
+        this.reporting.error(new Error('changes is undefined'));
         return;
       }
       this.$.confirmRevertDialog.populate(change, this.commitMessage, changes);
@@ -1150,9 +1179,9 @@
     const change = this.change;
     if (!change) return;
     const query = `submissionid:${change.submission_id}`;
-    this.$.restAPI.getChanges(0, query).then(changes => {
+    this.restApiService.getChanges(0, query).then(changes => {
       if (!changes) {
-        console.error('changes is undefined');
+        this.reporting.error(new Error('changes is undefined'));
         return;
       }
       this.$.confirmRevertSubmissionDialog._populateRevertSubmissionMessage(
@@ -1244,7 +1273,7 @@
         this._showActionDialog(this.$.confirmAbandonDialog);
         break;
       case QUICK_APPROVE_ACTION.key: {
-        const action = this._allActionValues.find(isQuckApproveAction);
+        const action = this._allActionValues.find(isQuickApproveAction);
         if (!action) {
           return;
         }
@@ -1370,23 +1399,11 @@
   _handleCherryPickRestApi(conflicts: boolean) {
     const el = this.$.confirmCherrypick;
     if (!el.branch) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_BRANCH_EMPTY},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_BRANCH_EMPTY);
       return;
     }
     if (!el.message) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_COMMIT_EMPTY},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_COMMIT_EMPTY);
       return;
     }
     this.$.overlay.close();
@@ -1407,13 +1424,7 @@
   _handleMoveConfirm() {
     const el = this.$.confirmMove;
     if (!el.branch) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_BRANCH_EMPTY},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_BRANCH_EMPTY);
       return;
     }
     this.$.overlay.close();
@@ -1448,7 +1459,7 @@
         );
         break;
       default:
-        console.error('invalid revert type');
+        this.reporting.error(new Error('invalid revert type'));
     }
   }
 
@@ -1584,7 +1595,7 @@
 
   _showActionDialog(dialog: ChangeActionDialog) {
     this._hideAllDialogs();
-
+    if (dialog.init) dialog.init();
     dialog.hidden = false;
     this.$.overlay.open().then(() => {
       if (dialog.resetFocus) {
@@ -1595,24 +1606,24 @@
 
   // TODO(rmistry): Redo this after
   // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
-  _setLabelValuesOnRevert(newChangeId: NumericChangeId) {
-    const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
-    if (!labels) {
+  _setReviewOnRevert(newChangeId: NumericChangeId) {
+    const review = this.jsAPI.getReviewPostRevert(this.change);
+    if (!review) {
       return Promise.resolve(undefined);
     }
-    return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
+    return this.restApiService.saveChangeReview(newChangeId, CURRENT, review);
   }
 
   _handleResponse(action: UIActionInfo, response?: Response) {
     if (!response) {
       return;
     }
-    return this.$.restAPI.getResponseObject(response).then(obj => {
+    return this.restApiService.getResponseObject(response).then(obj => {
       switch (action.__key) {
         case ChangeActions.REVERT: {
           const revertChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
           this._waitForChangeReachable(revertChangeInfo._number)
-            .then(() => this._setLabelValuesOnRevert(revertChangeInfo._number))
+            .then(() => this._setReviewOnRevert(revertChangeInfo._number))
             .then(() => {
               GerritNav.navigateToChange(revertChangeInfo);
             });
@@ -1735,10 +1746,10 @@
         new Error('Properties change and changeNum must be set.')
       );
     }
-    return fetchChangeUpdates(change, this.$.restAPI).then(result => {
+    return fetchChangeUpdates(change, this.restApiService).then(result => {
       if (!result.isLatest) {
         this.dispatchEvent(
-          new CustomEvent('show-alert', {
+          new CustomEvent<ShowAlertEventDetail>('show-alert', {
             detail: {
               message:
                 'Cannot set label: a newer patch has been ' +
@@ -1766,7 +1777,7 @@
         return Promise.resolve(undefined);
       }
       const patchNum = revisionAction ? this.latestPatchNum : undefined;
-      return this.$.restAPI
+      return this.restApiService
         .executeChangeAction(
           changeNum,
           method,
@@ -1796,14 +1807,16 @@
       ListChangesOption.MESSAGES,
       ListChangesOption.ALL_REVISIONS
     );
-    this.$.restAPI.getChanges(0, query, undefined, options).then(changes => {
-      if (!changes) {
-        console.error('getChanges returns undefined');
-        return;
-      }
-      this.$.confirmCherrypick.updateChanges(changes);
-      this._showActionDialog(this.$.confirmCherrypick);
-    });
+    this.restApiService
+      .getChanges(0, query, undefined, options)
+      .then(changes => {
+        if (!changes) {
+          this.reporting.error(new Error('getChanges returns undefined'));
+          return;
+        }
+        this.$.confirmCherrypick.updateChanges(changes);
+        this._showActionDialog(this.$.confirmCherrypick);
+      });
   }
 
   _handleMoveTap() {
@@ -2053,12 +2066,12 @@
    *
    */
   _waitForChangeReachable(changeNum: NumericChangeId) {
-    let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
+    let attemptsRemaining = AWAIT_CHANGE_ATTEMPTS;
     return new Promise(resolve => {
       const check = () => {
-        attempsRemaining--;
+        attemptsRemaining--;
         // Pass a no-op error handler to avoid the "not found" error toast.
-        this.$.restAPI
+        this.restApiService
           .getChange(changeNum, () => {})
           .then(response => {
             // If the response is 404, the response will be undefined.
@@ -2067,8 +2080,8 @@
               return;
             }
 
-            if (attempsRemaining) {
-              this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
+            if (attemptsRemaining) {
+              setTimeout(check, AWAIT_CHANGE_TIMEOUT_MS);
             } else {
               resolve(false);
             }
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
index 4e315af..2d4ed32 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
@@ -216,6 +216,7 @@
       action="[[_revisionSubmitAction]]"
       on-cancel="_handleConfirmDialogCancel"
       on-confirm="_handleSubmitConfirm"
+      comment-threads="[[commentThreads]]"
       hidden=""
     ></gr-confirm-submit-dialog>
     <gr-dialog
@@ -225,9 +226,7 @@
       on-confirm="_handleCreateFollowUpChange"
       on-cancel="_handleCloseCreateFollowUpChange"
     >
-      <div class="header" slot="header">
-        Create Follow-Up Change
-      </div>
+      <div class="header" slot="header">Create Follow-Up Change</div>
       <div class="main" slot="main">
         <gr-create-change-dialog
           id="createFollowUpChange"
@@ -246,9 +245,7 @@
       on-cancel="_handleConfirmDialogCancel"
       on-confirm="_handleDeleteConfirm"
     >
-      <div class="header" slot="header">
-        Delete Change
-      </div>
+      <div class="header" slot="header">Delete Change</div>
       <div class="main" slot="main">
         Do you really want to delete the change?
       </div>
@@ -261,14 +258,8 @@
       on-cancel="_handleConfirmDialogCancel"
       on-confirm="_handleDeleteEditConfirm"
     >
-      <div class="header" slot="header">
-        Delete Change Edit
-      </div>
-      <div class="main" slot="main">
-        Do you really want to delete the edit?
-      </div>
+      <div class="header" slot="header">Delete Change Edit</div>
+      <div class="main" slot="main">Do you really want to delete the edit?</div>
     </gr-dialog>
   </gr-overlay>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
index ae49a57..c193e60 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
@@ -21,10 +21,14 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 import {
+  createAccountWithId,
+  createApproval,
   createChange,
   createChangeMessages,
   createRevisions,
 } from '../../../test/test-data-generators.js';
+import {ChangeStatus} from '../../../constants/constants.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-change-actions');
 
@@ -38,56 +42,50 @@
 
   suite('basic tests', () => {
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getChangeRevisionActions() {
+      stubRestApi('getChangeRevisionActions').returns(Promise.resolve({
+        cherrypick: {
+          method: 'POST',
+          label: 'Cherry Pick',
+          title: 'Cherry pick change to a different branch',
+          enabled: true,
+        },
+        rebase: {
+          method: 'POST',
+          label: 'Rebase',
+          title: 'Rebase onto tip of branch or parent change',
+          enabled: true,
+        },
+        submit: {
+          method: 'POST',
+          label: 'Submit',
+          title: 'Submit patch set 2 into master',
+          enabled: true,
+        },
+        revert_submission: {
+          method: 'POST',
+          label: 'Revert submission',
+          title: 'Revert this submission',
+          enabled: true,
+        },
+      }));
+      stubRestApi('send').callsFake((method, url, payload) => {
+        if (method !== 'POST') {
+          return Promise.reject(new Error('bad method'));
+        }
+        if (url === '/changes/test~42/revisions/2/submit') {
           return Promise.resolve({
-            cherrypick: {
-              method: 'POST',
-              label: 'Cherry Pick',
-              title: 'Cherry pick change to a different branch',
-              enabled: true,
-            },
-            rebase: {
-              method: 'POST',
-              label: 'Rebase',
-              title: 'Rebase onto tip of branch or parent change',
-              enabled: true,
-            },
-            submit: {
-              method: 'POST',
-              label: 'Submit',
-              title: 'Submit patch set 2 into master',
-              enabled: true,
-            },
-            revert_submission: {
-              method: 'POST',
-              label: 'Revert submission',
-              title: 'Revert this submission',
-              enabled: true,
-            },
+            ok: true,
+            text() { return Promise.resolve(')]}\'\n{}'); },
           });
-        },
-        send(method, url, payload) {
-          if (method !== 'POST') {
-            return Promise.reject(new Error('bad method'));
-          }
-
-          if (url === '/changes/test~42/revisions/2/submit') {
-            return Promise.resolve({
-              ok: true,
-              text() { return Promise.resolve(')]}\'\n{}'); },
-            });
-          } else if (url === '/changes/test~42/revisions/2/rebase') {
-            return Promise.resolve({
-              ok: true,
-              text() { return Promise.resolve(')]}\'\n{}'); },
-            });
-          }
-
-          return Promise.reject(new Error('bad url'));
-        },
-        getProjectConfig() { return Promise.resolve({}); },
+        } else if (url === '/changes/test~42/revisions/2/rebase') {
+          return Promise.resolve({
+            ok: true,
+            text() { return Promise.resolve(')]}\'\n{}'); },
+          });
+        }
+        return Promise.reject(new Error('bad url'));
       });
+      stubRestApi('getProjectConfig').returns(Promise.resolve({}));
 
       sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
           .returns(Promise.resolve());
@@ -104,10 +102,10 @@
           enabled: true,
         },
       };
-      sinon.stub(element.$.confirmCherrypick.$.restAPI,
-          'getRepoBranches').returns(Promise.resolve([]));
-      sinon.stub(element.$.confirmMove.$.restAPI,
-          'getRepoBranches').returns(Promise.resolve([]));
+      element.account = {
+        _account_id: 123,
+      };
+      stubRestApi('getRepoBranches').returns(Promise.resolve([]));
 
       return element.reload();
     });
@@ -143,14 +141,14 @@
     });
 
     test('plugin revision actions', done => {
-      sinon.stub(element.$.restAPI, 'getChangeActionURL').returns(
+      const stub = stubRestApi('getChangeActionURL').returns(
           Promise.resolve('the-url'));
       element.revisionActions = {
         'plugin~action': {},
       };
       assert.isOk(element.revisionActions['plugin~action']);
       flush(() => {
-        assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
+        assert.isTrue(stub.calledWith(
             element.changeNum, element.latestPatchNum, '/plugin~action'));
         assert.equal(element.revisionActions['plugin~action'].__url, 'the-url');
         done();
@@ -158,14 +156,14 @@
     });
 
     test('plugin change actions', async () => {
-      sinon.stub(element.$.restAPI, 'getChangeActionURL').returns(
+      const stub = stubRestApi('getChangeActionURL').returns(
           Promise.resolve('the-url'));
       element.actions = {
         'plugin~action': {},
       };
       assert.isOk(element.actions['plugin~action']);
       await flush();
-      assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
+      assert.isTrue(stub.calledWith(
           element.changeNum, undefined, '/plugin~action'));
       assert.equal(element.actions['plugin~action'].__url, 'the-url');
     });
@@ -253,7 +251,7 @@
           rev2: revObj,
         },
       };
-      assert.deepEqual(element._getRevision(change, '2'), revObj);
+      assert.deepEqual(element._getRevision(change, 2), revObj);
     });
 
     test('_actionComparator sort order', () => {
@@ -273,7 +271,7 @@
 
     test('submit change', () => {
       const showSpy = sinon.spy(element, '_showActionDialog');
-      sinon.stub(element.$.restAPI, 'getFromProjectLookup')
+      stubRestApi('getFromProjectLookup')
           .returns(Promise.resolve('test'));
       sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
       element.change = {
@@ -295,7 +293,7 @@
 
     test('submit change, tap on icon', done => {
       sinon.stub(element.$.confirmSubmitDialog, 'resetFocus').callsFake( done);
-      sinon.stub(element.$.restAPI, 'getFromProjectLookup')
+      stubRestApi('getFromProjectLookup')
           .returns(Promise.resolve('test'));
       sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
       element.change = {
@@ -399,7 +397,7 @@
 
     test('rebase change fires reload event', done => {
       const eventStub = sinon.stub(element, 'dispatchEvent');
-      sinon.stub(element.$.restAPI, 'getResponseObject').returns(
+      stubRestApi('getResponseObject').returns(
           Promise.resolve({}));
       element._handleResponse({__key: 'rebase'}, {});
       flush(() => {
@@ -429,24 +427,21 @@
       });
     });
 
-    test('two dialogs are not shown at the same time', done => {
+    test('two dialogs are not shown at the same time', async () => {
       element._hasKnownChainState = true;
-      flush(() => {
-        const rebaseButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebase"]');
-        assert.ok(rebaseButton);
-        MockInteractions.tap(rebaseButton);
-        flush();
-        assert.isFalse(element.$.confirmRebase.hidden);
-        sinon.stub(element.$.restAPI, 'getChanges')
-            .returns(Promise.resolve([]));
-        element._handleCherrypickTap();
-        flush(() => {
-          assert.isTrue(element.$.confirmRebase.hidden);
-          assert.isFalse(element.$.confirmCherrypick.hidden);
-          done();
-        });
-      });
+      await flush();
+      const rebaseButton = element.shadowRoot
+          .querySelector('gr-button[data-action-key="rebase"]');
+      assert.ok(rebaseButton);
+      MockInteractions.tap(rebaseButton);
+      await flush();
+      assert.isFalse(element.$.confirmRebase.hidden);
+      stubRestApi('getChanges')
+          .returns(Promise.resolve([]));
+      element._handleCherrypickTap();
+      await flush();
+      assert.isTrue(element.$.confirmRebase.hidden);
+      assert.isFalse(element.$.confirmCherrypick.hidden);
     });
 
     test('fullscreen-overlay-opened hides content', () => {
@@ -469,16 +464,16 @@
       assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
     });
 
-    test('_setLabelValuesOnRevert', () => {
-      const labels = {'Foo': 1, 'Bar-Baz': -2};
+    test('_setReviewOnRevert', () => {
+      const review = {labels: {'Foo': 1, 'Bar-Baz': -2}};
       const changeId = 1234;
-      sinon.stub(element.$.jsAPI, 'getLabelValuesPostRevert').returns(labels);
-      const saveStub = sinon.stub(element.$.restAPI, 'saveChangeReview')
+      sinon.stub(element.jsAPI, 'getReviewPostRevert').returns(review);
+      const saveStub = stubRestApi('saveChangeReview')
           .returns(Promise.resolve());
-      return element._setLabelValuesOnRevert(changeId).then(() => {
+      return element._setReviewOnRevert(changeId).then(() => {
         assert.isTrue(saveStub.calledOnce);
         assert.equal(saveStub.lastCall.args[0], changeId);
-        assert.deepEqual(saveStub.lastCall.args[2], {labels});
+        assert.deepEqual(saveStub.lastCall.args[2], review);
       });
     });
 
@@ -740,15 +735,15 @@
         const changes = [
           {
             change_id: '12345678901234', topic: 'T', subject: 'random',
-            project: 'A',
+            project: 'A', status: 'MERGED',
           },
           {
             change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
-            project: 'B',
+            project: 'B', status: 'NEW',
           },
         ];
         setup(done => {
-          sinon.stub(element.$.restAPI, 'getChanges')
+          stubRestApi('getChanges')
               .returns(Promise.resolve(changes));
           element._handleCherrypickTap();
           flush(() => {
@@ -767,8 +762,8 @@
           flush(() => {
             const changesTable = dialog.shadowRoot.querySelector('table');
             const headers = Array.from(changesTable.querySelectorAll('th'));
-            const expectedHeadings = ['Change', 'Subject', 'Project',
-              'Status', ''];
+            const expectedHeadings = ['', 'Change', 'Status', 'Subject',
+              'Project', 'Progress', ''];
             const headings = headers.map(header => header.innerText);
             assert.equal(headings.length, expectedHeadings.length);
             for (let i = 0; i < headings.length; i++) {
@@ -777,7 +772,7 @@
             const changeRows = changesTable.querySelectorAll('tbody > tr');
             const change = Array.from(changeRows[0].querySelectorAll('td'))
                 .map(e => e.innerText);
-            const expectedChange = ['1234567890', 'random', 'A',
+            const expectedChange = ['', '1234567890', 'MERGED', 'random', 'A',
               'NOT STARTED', ''];
             for (let i = 0; i < change.length; i++) {
               assert.equal(change[i].trim(), expectedChange[i]);
@@ -994,7 +989,7 @@
         element.change = {
           current_revision: 'abc1234',
         };
-        sinon.stub(element.$.restAPI, 'getChanges')
+        stubRestApi('getChanges')
             .returns(Promise.resolve([
               {change_id: '12345678901234', topic: 'T', subject: 'random'},
               {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
@@ -1019,7 +1014,7 @@
             submission_id: '199 0',
             current_revision: '2000',
           };
-          getChangesStub = sinon.stub(element.$.restAPI, 'getChanges')
+          getChangesStub = stubRestApi('getChanges')
               .returns(Promise.resolve([
                 {change_id: '12345678901234', topic: 'T', subject: 'random'},
                 {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
@@ -1129,7 +1124,7 @@
             submission_id: '199',
             current_revision: '2000',
           };
-          sinon.stub(element.$.restAPI, 'getChanges')
+          stubRestApi('getChanges')
               .returns(Promise.resolve([
                 {change_id: '12345678901234', topic: 'T', subject: 'random'},
               ]));
@@ -1529,9 +1524,6 @@
       setup(() => {
         element.change = {
           current_revision: 'abc1234',
-        };
-        element.change = {
-          current_revision: 'abc1234',
           labels: {
             foo: {
               values: {
@@ -1578,6 +1570,16 @@
         assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
       });
 
+      test('not added when change is merged', () => {
+        element.change.status = ChangeStatus.MERGED;
+        flush(() => {
+          const approveButton =
+          element.shadowRoot
+              .querySelector('gr-button[data-action-key=\'review\']');
+          assert.isNull(approveButton);
+        });
+      });
+
       test('not added when already approved', () => {
         element.change = {
           current_revision: 'abc1234',
@@ -1627,23 +1629,52 @@
         assert.deepEqual(payload.labels, {foo: 1});
       });
 
-      test('not added when multiple labels are required', () => {
+      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: {}},
+            'foo': {values: {}},
+            'bar': {values: {}},
+            'Code-Review': {
+              approved: {},
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
           },
           permitted_labels: {
-            foo: [' 0', '+1'],
-            bar: [' 0', '+1', '+2'],
+            '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.isNull(approveButton);
+        assert.isOk(approveButton);
       });
 
       test('button label for missing approval', () => {
@@ -1717,6 +1748,87 @@
                 .querySelector('gr-button[data-action-key=\'review\']');
         assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
       });
+
+      test('added when can approve an already-approved code review label',
+          () => {
+            element.change = {
+              current_revision: 'abc1234',
+              labels: {
+                'Code-Review': {
+                  approved: {},
+                  values: {
+                    ' 0': '',
+                    '+1': '',
+                    '+2': '',
+                  },
+                },
+              },
+              permitted_labels: {
+                'Code-Review': [' 0', '+1', '+2'],
+              },
+            };
+            flush();
+            const approveButton =
+                element.shadowRoot
+                    .querySelector('gr-button[data-action-key=\'review\']');
+            assert.isNotNull(approveButton);
+          });
+
+      test('not added when the user has already approved', () => {
+        const vote = {
+          ...createApproval(),
+          _account_id: 123,
+          name: 'name',
+          value: 2,
+        };
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            'Code-Review': {
+              approved: {},
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+              all: [vote],
+            },
+          },
+          permitted_labels: {
+            'Code-Review': [' 0', '+1', '+2'],
+          },
+        };
+        flush();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('not added when user owns the change', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          owner: createAccountWithId(123),
+          labels: {
+            'Code-Review': {
+              approved: {},
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            'Code-Review': [' 0', '+1', '+2'],
+          },
+        };
+        flush();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
     });
 
     test('adds download revision action', () => {
@@ -1771,8 +1883,9 @@
       });
 
       suite('_waitForChangeReachable', () => {
+        let clock;
         setup(() => {
-          sinon.stub(element, 'async').callsFake( fn => fn());
+          clock = sinon.useFakeTimers();
         });
 
         const makeGetChange = numTries => () => {
@@ -1784,20 +1897,27 @@
           }
         };
 
-        test('succeed', () => {
-          sinon.stub(element.$.restAPI, 'getChange')
-              .callsFake( makeGetChange(5));
-          return element._waitForChangeReachable(123).then(success => {
-            assert.isTrue(success);
-          });
+        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', () => {
-          sinon.stub(element.$.restAPI, 'getChange')
-              .callsFake( makeGetChange(6));
-          return element._waitForChangeReachable(123).then(success => {
-            assert.isFalse(success);
-          });
+        test('fail', async () => {
+          stubRestApi('getChange').callsFake(makeGetChange(6));
+          const promise = element._waitForChangeReachable(123);
+          tickAndFlush(6);
+          const success = await promise;
+          assert.isFalse(success);
         });
       });
     });
@@ -1830,16 +1950,16 @@
       suite('happy path', () => {
         let sendStub;
         setup(() => {
-          sinon.stub(element.$.restAPI, 'getChangeDetail')
+          stubRestApi('getChangeDetail')
               .returns(Promise.resolve({
                 ...createChange(),
                 // element has latest info
                 revisions: createRevisions(element.latestPatchNum),
                 messages: createChangeMessages(1),
               }));
-          sendStub = sinon.stub(element.$.restAPI, 'executeChangeAction')
+          sendStub = stubRestApi('executeChangeAction')
               .returns(Promise.resolve({}));
-          getResponseObjectStub = sinon.stub(element.$.restAPI,
+          getResponseObjectStub = stubRestApi(
               'getResponseObject');
           sinon.stub(GerritNav,
               'navigateToChange').returns(Promise.resolve(true));
@@ -1857,7 +1977,7 @@
           setup(() => {
             element.change.submission_id = '199';
             element.change.current_revision = '2000';
-            sinon.stub(element.$.restAPI, 'getChanges')
+            stubRestApi('getChanges')
                 .returns(Promise.resolve([
                   {change_id: '12345678901234', topic: 'T', subject: 'random'},
                   {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
@@ -1949,14 +2069,14 @@
 
       suite('failure modes', () => {
         test('non-latest', () => {
-          sinon.stub(element.$.restAPI, 'getChangeDetail')
+          stubRestApi('getChangeDetail')
               .returns(Promise.resolve({
                 ...createChange(),
                 // new patchset was uploaded
                 revisions: createRevisions(element.latestPatchNum + 1),
                 messages: createChangeMessages(1),
               }));
-          const sendStub = sinon.stub(element.$.restAPI,
+          const sendStub = stubRestApi(
               'executeChangeAction');
 
           return element._send('DELETE', payload, '/endpoint', true, cleanup)
@@ -1969,14 +2089,14 @@
         });
 
         test('send fails', () => {
-          sinon.stub(element.$.restAPI, 'getChangeDetail')
+          stubRestApi('getChangeDetail')
               .returns(Promise.resolve({
                 ...createChange(),
                 // element has latest info
                 revisions: createRevisions(element.latestPatchNum),
                 messages: createChangeMessages(1),
               }));
-          const sendStub = sinon.stub(element.$.restAPI,
+          const sendStub = stubRestApi(
               'executeChangeAction').callsFake(
               (num, method, patchNum, endpoint, payload, onErr) => {
                 onErr();
@@ -2017,30 +2137,22 @@
     let changeRevisionActions;
 
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getChangeRevisionActions() {
-          return Promise.resolve(changeRevisionActions);
-        },
-        send(method, url, payload) {
-          return Promise.reject(new Error('error'));
-        },
-        getProjectConfig() { return Promise.resolve({}); },
-      });
+      stubRestApi('getChangeRevisionActions').returns(
+          Promise.resolve(changeRevisionActions));
+      stubRestApi('send').returns(Promise.reject(new Error('error')));
+      stubRestApi('getProjectConfig').returns(Promise.resolve({}));
 
       sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
           .returns(Promise.resolve());
 
       element = basicFixture.instantiate();
       // getChangeRevisionActions is not called without
-      // set the following properies
+      // set the following properties
       element.change = {};
       element.changeNum = '42';
       element.latestPatchNum = '2';
 
-      sinon.stub(element.$.confirmCherrypick.$.restAPI,
-          'getRepoBranches').returns(Promise.resolve([]));
-      sinon.stub(element.$.confirmMove.$.restAPI,
-          'getRepoBranches').returns(Promise.resolve([]));
+      stubRestApi('getRepoBranches').returns(Promise.resolve([]));
       return element.reload();
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
deleted file mode 100644
index e812657..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
+++ /dev/null
@@ -1,185 +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 {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import './gr-change-metadata.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const testHtmlPlugin = document.createElement('dom-module');
-testHtmlPlugin.innerHTML = `
-    <template>
-      <style>
-        html {
-          --change-metadata-assignee: {
-            display: none;
-          }
-          --change-metadata-label-status: {
-            display: none;
-          }
-          --change-metadata-strategy: {
-            display: none;
-          }
-          --change-metadata-topic: {
-            display: none;
-          }
-        }
-      </style>
-    </template>
-  `;
-testHtmlPlugin.register('my-plugin-style');
-
-const basicFixture = fixtureFromTemplate(
-    html`<gr-change-metadata mutable="true"></gr-change-metadata>`
-);
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-change-metadata integration tests', () => {
-  let element;
-
-  const sectionSelectors = [
-    'section.strategy',
-    'section.topic',
-  ];
-
-  const labels = {
-    CI: {
-      all: [
-        {value: 1, name: 'user 2', _account_id: 1},
-        {value: 2, name: 'user '},
-      ],
-      values: {
-        ' 0': 'Don\'t submit as-is',
-        '+1': 'No score',
-        '+2': 'Looks good to me',
-      },
-    },
-  };
-
-  const getStyle = function(selector, name) {
-    return window.getComputedStyle(
-        element.root.querySelector(selector))[name];
-  };
-
-  function createElement() {
-    const element = basicFixture.instantiate();
-    element.change = {labels, status: 'NEW'};
-    element.revision = {};
-    return element;
-  }
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getLoggedIn() { return Promise.resolve(false); },
-      deleteVote() { return Promise.resolve({ok: true}); },
-    });
-  });
-
-  teardown(() => {
-    resetPlugins();
-  });
-
-  suite('by default', () => {
-    setup(done => {
-      element = createElement();
-      flush(done);
-    });
-
-    for (const sectionSelector of sectionSelectors) {
-      test(sectionSelector + ' does not have display: none', () => {
-        assert.notEqual(getStyle(sectionSelector, 'display'), 'none');
-      });
-    }
-  });
-
-  suite('with plugin style', () => {
-    setup(done => {
-      resetPlugins();
-      pluginApi.install(plugin => {
-        plugin.registerStyleModule('change-metadata', 'my-plugin-style');
-      }, undefined, 'http://test.com/plugins/style.js');
-      element = createElement();
-      getPluginLoader().loadPlugins([]);
-      getPluginLoader().awaitPluginsLoaded()
-          .then(() => {
-            flush(done);
-          });
-    });
-
-    teardown(() => {
-      document.body.querySelectorAll('custom-style')
-          .forEach(style => style.remove());
-    });
-
-    for (const sectionSelector of sectionSelectors) {
-      test('section.strategy may have display: none', () => {
-        assert.equal(getStyle(sectionSelector, 'display'), 'none');
-      });
-    }
-  });
-
-  suite('label updates', () => {
-    let plugin;
-
-    setup(() => {
-      pluginApi.install(p => {
-        plugin = p;
-        plugin.registerStyleModule('change-metadata', 'my-plugin-style');
-      }, undefined, 'http://test.com/plugins/style.js');
-      sinon.stub(getPluginLoader(), 'arePluginsLoaded').returns(true);
-      getPluginLoader().loadPlugins([]);
-      element = createElement();
-    });
-
-    teardown(() => {
-      document.body.querySelectorAll('custom-style')
-          .forEach(style => style.remove());
-    });
-
-    test('labels changed callback', done => {
-      let callCount = 0;
-      const labelChangeSpy = sinon.spy(arg => {
-        callCount++;
-        if (callCount === 1) {
-          assert.deepEqual(arg, labels);
-          assert.equal(arg.CI.all.length, 2);
-          element.set(['change', 'labels'], {
-            CI: {
-              all: [
-                {value: 1, name: 'user 2', _account_id: 1},
-              ],
-              values: {
-                ' 0': 'Don\'t submit as-is',
-                '+1': 'No score',
-                '+2': 'Looks good to me',
-              },
-            },
-          });
-        } else if (callCount === 2) {
-          assert.equal(arg.CI.all.length, 1);
-          done();
-        }
-      });
-
-      plugin.changeMetadata().onLabelsChanged(labelChangeSpy);
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index f8a5940..6749ed1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -28,14 +28,11 @@
 import '../../shared/gr-limited-text/gr-limited-text';
 import '../../shared/gr-linked-chip/gr-linked-chip';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-change-requirements/gr-change-requirements';
 import '../gr-commit-info/gr-commit-info';
 import '../gr-reviewer-list/gr-reviewer-list';
 import '../../shared/gr-account-list/gr-account-list';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-metadata_html';
 import {
@@ -51,10 +48,6 @@
 import {changeIsOpen} from '../../../utils/change-util';
 import {customElement, property, observe} from '@polymer/decorators';
 import {
-  EditRevisionInfo,
-  ParsedChangeInfo,
-} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {
   AccountDetailInfo,
   AccountInfo,
   BranchName,
@@ -72,10 +65,25 @@
   ServerInfo,
   TopicName,
 } from '../../../types/common';
-import {assertNever} from '../../../utils/common-util';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {assertNever, unique} from '../../../utils/common-util';
 import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
 import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip';
+import {appContext} from '../../../services/app-context';
+import {
+  Metadata,
+  isSectionSet,
+  DisplayRules,
+} from '../../../utils/change-metadata-util';
+import {fireEvent} from '../../../utils/event-util';
+import {
+  EditRevisionInfo,
+  notUndefined,
+  ParsedChangeInfo,
+} from '../../../types/types';
+import {
+  AutocompleteQuery,
+  AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -102,7 +110,7 @@
 
 const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
 
-interface PushCertifacteValidationInfo {
+interface PushCertificateValidationInfo {
   class: string;
   icon: string;
   message: string;
@@ -110,14 +118,12 @@
 
 export interface GrChangeMetadata {
   $: {
-    restAPI: RestApiService & Element;
+    webLinks: HTMLElement;
   };
 }
 
 @customElement('gr-change-metadata')
-export class GrChangeMetadata extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrChangeMetadata extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -171,7 +177,7 @@
     type: Object,
     computed: '_computePushCertificateValidation(serverConfig, change)',
   })
-  _pushCertificateValidation: PushCertifacteValidationInfo | null = null;
+  _pushCertificateValidation?: PushCertificateValidationInfo;
 
   @property({type: Boolean, computed: '_computeShowRequirements(change)'})
   _showRequirements = false;
@@ -194,9 +200,28 @@
   @property({type: Object})
   _CHANGE_ROLE = ChangeRole;
 
+  @property({type: Object})
+  _SECTION = Metadata;
+
+  @property({type: Boolean})
+  _showAllSections = false;
+
+  @property({type: Object})
+  queryTopic?: AutocompleteQuery;
+
+  restApiService = appContext.restApiService;
+
+  private readonly reporting = appContext.reportingService;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this.queryTopic = (input: string) => this._getTopicSuggestions(input);
+  }
+
   @observe('change.labels')
   _labelsChanged(labels?: LabelNameToInfoMap) {
-    this.labels = {...labels} || null;
+    this.labels = {...labels};
   }
 
   @observe('change')
@@ -223,13 +248,13 @@
         return;
       }
       this.set(['change', 'assignee'], acct);
-      this.$.restAPI.setAssignee(this.change._number, acct._account_id);
+      this.restApiService.setAssignee(this.change._number, acct._account_id);
     } else {
       if (!this.change.assignee) {
         return;
       }
       this.set(['change', 'assignee'], undefined);
-      this.$.restAPI.deleteAssignee(this.change._number);
+      this.restApiService.deleteAssignee(this.change._number);
     }
   }
 
@@ -238,7 +263,7 @@
   }
 
   /**
-   * @return If array is empty, returns null instead so
+   * @return If array is empty, returns undefined instead so
    * an existential check can be used to hide or show the webLinks
    * section.
    */
@@ -246,9 +271,7 @@
     commitInfo?: CommitInfoWithRequiredCommit,
     serverConfig?: ServerInfo
   ) {
-    if (!commitInfo) {
-      return null;
-    }
+    if (!commitInfo) return undefined;
     const weblinks = GerritNav.getChangeWeblinks(
       this.change ? this.change.project : ('' as RepoName),
       commitInfo.commit,
@@ -257,15 +280,15 @@
         config: serverConfig,
       }
     );
-    return weblinks.length ? weblinks : null;
+    return weblinks.length ? weblinks : undefined;
+  }
+
+  _isChangeMerged(change?: ParsedChangeInfo) {
+    return change?.status === ChangeStatus.MERGED;
   }
 
   _isAssigneeEnabled(serverConfig?: ServerInfo) {
-    return (
-      serverConfig &&
-      serverConfig.change &&
-      !!serverConfig.change.enable_assignee
-    );
+    return !!serverConfig?.change?.enable_assignee;
   }
 
   _computeStrategy(change?: ParsedChangeInfo) {
@@ -285,54 +308,43 @@
       throw new Error('change must be set');
     }
     const lastTopic = this.change.topic;
-    const topic = e.detail.length ? e.detail : null;
+    const topic = e.detail.length ? e.detail : undefined;
     this._settingTopic = true;
     const topicChangedForChangeNumber = this.change._number;
-    this.$.restAPI
+    this.restApiService
       .setChangeTopic(topicChangedForChangeNumber, topic)
       .then(newTopic => {
-        if (
-          !this.change ||
-          this.change._number !== topicChangedForChangeNumber
-        ) {
-          return;
-        }
+        if (this.change?._number !== topicChangedForChangeNumber) return;
         this._settingTopic = false;
         this.set(['change', 'topic'], newTopic);
         if (newTopic !== lastTopic) {
-          this.dispatchEvent(
-            new CustomEvent('topic-changed', {bubbles: true, composed: true})
-          );
+          fireEvent(this, 'topic-changed');
         }
       });
   }
 
   _showAddTopic(
-    changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
+    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
     settingTopic?: boolean
   ) {
-    const hasTopic =
-      !!changeRecord && !!changeRecord.base && !!changeRecord.base.topic;
+    const hasTopic = !!changeRecord?.base?.topic;
     return !hasTopic && !settingTopic;
   }
 
   _showTopicChip(
-    changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
+    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
     settingTopic?: boolean
   ) {
-    const hasTopic =
-      !!changeRecord && !!changeRecord.base && !!changeRecord.base.topic;
+    const hasTopic = !!changeRecord?.base?.topic;
     return hasTopic && !settingTopic;
   }
 
   _showCherryPickOf(
-    changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>
+    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>
   ) {
     const hasCherryPickOf =
-      !!changeRecord &&
-      !!changeRecord.base &&
-      !!changeRecord.base.cherry_pick_of_change &&
-      !!changeRecord.base.cherry_pick_of_patch_set;
+      !!changeRecord?.base?.cherry_pick_of_change &&
+      !!changeRecord?.base?.cherry_pick_of_patch_set;
     return hasCherryPickOf;
   }
 
@@ -345,47 +357,24 @@
     }
     const newHashtag = this._newHashtag;
     this._newHashtag = '' as Hashtag;
-    this.$.restAPI
+    this.restApiService
       .setChangeHashtag(this.change._number, {add: [newHashtag]})
       .then(newHashtag => {
         this.set(['change', 'hashtags'], newHashtag);
-        this.dispatchEvent(
-          new CustomEvent('hashtag-changed', {
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireEvent(this, 'hashtag-changed');
       });
   }
 
   _computeTopicReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
-    return (
-      !mutable ||
-      !change ||
-      !change.actions ||
-      !change.actions.topic ||
-      !change.actions.topic.enabled
-    );
+    return !mutable || !change?.actions?.topic?.enabled;
   }
 
   _computeHashtagReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
-    return (
-      !mutable ||
-      !change ||
-      !change.actions ||
-      !change.actions.hashtags ||
-      !change.actions.hashtags.enabled
-    );
+    return !mutable || !change?.actions?.hashtags?.enabled;
   }
 
   _computeAssigneeReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
-    return (
-      !mutable ||
-      !change ||
-      !change.actions ||
-      !change.actions.assignee ||
-      !change.actions.assignee.enabled
-    );
+    return !mutable || !change?.actions?.assignee?.enabled;
   }
 
   _computeTopicPlaceholder(_topicReadOnly?: boolean) {
@@ -419,17 +408,11 @@
   _computePushCertificateValidation(
     serverConfig?: ServerInfo,
     change?: ParsedChangeInfo
-  ): PushCertifacteValidationInfo | null {
-    if (
-      !change ||
-      !serverConfig ||
-      !serverConfig.receive ||
-      !serverConfig.receive.enable_signed_push
-    ) {
-      return null;
-    }
+  ): PushCertificateValidationInfo | undefined {
+    if (!change || !serverConfig?.receive?.enable_signed_push) return undefined;
+
     const rev = change.revisions[change.current_revision];
-    if (!rev.push_certificate || !rev.push_certificate.key) {
+    if (!rev.push_certificate?.key) {
       return {
         class: 'help',
         icon: 'gr-icons:help',
@@ -472,7 +455,7 @@
   }
 
   _problems(msg: string, key: GpgKeyInfo) {
-    if (!key || !key.problems || key.problems.length === 0) {
+    if (!key?.problems || key.problems.length === 0) {
       return msg;
     }
 
@@ -524,18 +507,15 @@
     }
     const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
     target.disabled = true;
-    this.$.restAPI
-      .setChangeTopic(this.change._number, null)
+    this.restApiService
+      .setChangeTopic(this.change._number)
       .then(() => {
         target.disabled = false;
         this.set(['change', 'topic'], '');
-        this.dispatchEvent(
-          new CustomEvent('topic-changed', {bubbles: true, composed: true})
-        );
+        fireEvent(this, 'topic-changed');
       })
       .catch(() => {
         target.disabled = false;
-        return;
       });
   }
 
@@ -546,7 +526,7 @@
     }
     const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
     target.disabled = true;
-    this.$.restAPI
+    this.restApiService
       .setChangeHashtag(this.change._number, {remove: [target.text as Hashtag]})
       .then(newHashtags => {
         target.disabled = false;
@@ -554,35 +534,74 @@
       })
       .catch(() => {
         target.disabled = false;
-        return;
       });
   }
 
   _computeIsWip(change?: ParsedChangeInfo) {
-    return change && !!change.work_in_progress;
+    return !!change?.work_in_progress;
   }
 
   _computeShowRoleClass(change?: ParsedChangeInfo, role?: ChangeRole) {
     return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
   }
 
+  _computeDisplayState(
+    showAllSections: boolean,
+    change: ParsedChangeInfo | undefined,
+    section: Metadata
+  ) {
+    if (
+      showAllSections ||
+      DisplayRules.ALWAYS_SHOW.includes(section) ||
+      (DisplayRules.SHOW_IF_SET.includes(section) &&
+        isSectionSet(section, change))
+    ) {
+      return '';
+    }
+    return 'hideDisplay';
+  }
+
+  _computeMergedCommitInfo(
+    current_revision: CommitId,
+    revisions: {[revisionId: string]: RevisionInfo}
+  ) {
+    const rev = revisions[current_revision];
+    if (!rev || !rev.commit) {
+      return {};
+    }
+    // CommitInfo.commit is optional. Set commit in all cases to avoid error
+    // in <gr-commit-info>. @see Issue 5337
+    if (!rev.commit.commit) {
+      rev.commit.commit = current_revision;
+    }
+    return rev.commit;
+  }
+
+  _computeShowAllLabelText(showAllSections: boolean) {
+    if (showAllSections) {
+      return 'Show less';
+    } else {
+      return 'Show all';
+    }
+  }
+
+  _onShowAllClick() {
+    this._showAllSections = !this._showAllSections;
+    this.reporting.reportInteraction('toggle show all button', {
+      sectionName: 'metadata',
+      toState: this._showAllSections ? 'Show all' : 'Show less',
+    });
+  }
+
   /**
-   * Get the user with the specified role on the change. Returns null if the
+   * Get the user with the specified role on the change. Returns undefined if the
    * user with that role is the same as the owner.
    */
   _getNonOwnerRole(change?: ParsedChangeInfo, role?: ChangeRole) {
-    if (
-      !change ||
-      !change.current_revision ||
-      !change.revisions[change.current_revision]
-    ) {
-      return null;
-    }
+    if (!change?.revisions?.[change.current_revision]) return undefined;
 
     const rev = change.revisions[change.current_revision];
-    if (!rev) {
-      return null;
-    }
+    if (!rev) return undefined;
 
     if (
       role === ChangeRole.UPLOADER &&
@@ -611,7 +630,7 @@
       return rev.commit.committer;
     }
 
-    return null;
+    return undefined;
   }
 
   _computeParents(
@@ -670,13 +689,27 @@
       return undefined;
     }
     const provider = GrReviewerSuggestionsProvider.create(
-      this.$.restAPI,
+      this.restApiService,
       change._number,
       SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY
     );
     provider.init();
     return provider;
   }
+
+  _getTopicSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
+    return this.restApiService
+      .getChangesWithSimilarTopic(input)
+      .then(response =>
+        (response ?? [])
+          .map(change => change.topic)
+          .filter(notUndefined)
+          .filter(unique)
+          .map(topic => {
+            return {name: topic, value: topic};
+          })
+      );
+  }
 }
 
 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 f1d1127..ed38d6f 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -49,7 +49,15 @@
       pointer-events: none;
     }
     .hashtagChip {
-      margin-bottom: var(--spacing-m);
+      padding-bottom: var(--spacing-s);
+    }
+    /* consistent with section .title, .value */
+    .hashtagChip:not(last-of-type) {
+      padding-bottom: var(--spacing-s);
+    }
+    .hashtagChip:last-of-type {
+      display: inline;
+      vertical-align: top;
     }
     #externalStyle {
       display: block;
@@ -70,7 +78,7 @@
     }
     .icon.help,
     .icon.notTrusted {
-      color: #ffa62f;
+      color: var(--warning-foreground);
     }
     .icon.invalid {
       color: var(--negative-red-text-color);
@@ -79,7 +87,7 @@
       color: var(--positive-green-text-color);
     }
     .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
-      --arrow-color: #ffa62f;
+      --arrow-color: var(--warning-foreground);
       display: inline-block;
     }
     .separatedSection {
@@ -91,21 +99,79 @@
       --linked-chip-text-color: var(--link-color);
     }
     gr-reviewer-list {
-      --account-max-length: 120px;
+      --account-max-length: 100px;
       max-width: 285px;
     }
+    .metadata-title {
+      font-weight: var(--font-weight-bold);
+      color: var(--deemphasized-text-color);
+      padding-left: var(--metadata-horizontal-padding);
+    }
+    .metadata-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: flex-end;
+      /* The goal is to achieve alignment of the owner account chip and the
+         commit message box. Their top border should be on the same line. */
+      margin-bottom: var(--spacing-s);
+    }
+    .show-all-button iron-icon {
+      color: inherit;
+      --iron-icon-height: 18px;
+      --iron-icon-width: 18px;
+    }
   </style>
   <gr-external-style id="externalStyle" name="change-metadata">
-    <section>
-      <span class="title">Updated</span>
+    <div class="metadata-header">
+      <h3 class="metadata-title">Change Info</h3>
+      <gr-button link="" class="show-all-button" on-click="_onShowAllClick"
+        >[[_computeShowAllLabelText(_showAllSections)]]
+        <iron-icon
+          icon="gr-icons:expand-more"
+          hidden$="[[_showAllSections]]"
+        ></iron-icon
+        ><iron-icon
+          icon="gr-icons:expand-less"
+          hidden$="[[!_showAllSections]]"
+        ></iron-icon>
+      </gr-button>
+    </div>
+    <template is="dom-if" if="[[change.submitted]]">
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.SUBMITTED)]]"
+      >
+        <span class="title">Submitted</span>
+        <span class="value">
+          <gr-date-formatter
+            has-tooltip=""
+            date-str="[[change.submitted]]"
+            show-yesterday=""
+          ></gr-date-formatter>
+        </span>
+      </section>
+    </template>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.UPDATED)]]"
+    >
+      <span class="title">
+        <gr-tooltip-content
+          has-tooltip=""
+          title="Last update of (meta)data for this change."
+        >
+          Updated
+        </gr-tooltip-content>
+      </span>
       <span class="value">
         <gr-date-formatter
           has-tooltip=""
           date-str="[[change.updated]]"
+          show-yesterday=""
         ></gr-date-formatter>
       </span>
     </section>
-    <section>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.OWNER)]]"
+    >
       <span class="title">Owner</span>
       <span class="value">
         <gr-account-chip
@@ -156,7 +222,9 @@
       </span>
     </section>
     <template is="dom-if" if="[[_isAssigneeEnabled(serverConfig)]]">
-      <section class="assignee">
+      <section
+        class$="assignee [[_computeDisplayState(_showAllSections, change, _SECTION.ASSIGNEE)]]"
+      >
         <span class="title">Assignee</span>
         <span class="value">
           <gr-account-list
@@ -172,24 +240,30 @@
         </span>
       </section>
     </template>
-    <section>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REVIEWERS)]]"
+    >
       <span class="title">Reviewers</span>
       <span class="value">
         <gr-reviewer-list
           change="{{change}}"
           mutable="[[_mutable]]"
           reviewers-only=""
+          account="[[account]]"
           server-config="[[serverConfig]]"
         ></gr-reviewer-list>
       </span>
     </section>
-    <section>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.CC)]]"
+    >
       <span class="title">CC</span>
       <span class="value">
         <gr-reviewer-list
           change="{{change}}"
           mutable="[[_mutable]]"
           ccs-only=""
+          account="[[account]]"
           server-config="[[serverConfig]]"
         ></gr-reviewer-list>
       </span>
@@ -198,7 +272,9 @@
       is="dom-if"
       if="[[_computeShowRepoBranchTogether(change.project, change.branch)]]"
     >
-      <section>
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
+      >
         <span class="title">Repo | Branch</span>
         <span class="value">
           <a href$="[[_computeProjectUrl(change.project)]]"
@@ -215,7 +291,9 @@
       is="dom-if"
       if="[[!_computeShowRepoBranchTogether(change.project, change.branch)]]"
     >
-      <section>
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
+      >
         <span class="title">Repo</span>
         <span class="value">
           <a href$="[[_computeProjectUrl(change.project)]]">
@@ -226,7 +304,9 @@
           </a>
         </span>
       </section>
-      <section>
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
+      >
         <span class="title">Branch</span>
         <span class="value">
           <a href$="[[_computeBranchUrl(change.project, change.branch)]]">
@@ -238,7 +318,9 @@
         </span>
       </section>
     </template>
-    <section>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.PARENT)]]"
+    >
       <span class="title">[[_computeParentsLabel(_currentParents)]]</span>
       <span class="value">
         <ol
@@ -262,7 +344,23 @@
         </ol>
       </span>
     </section>
-    <section class="topic">
+    <template is="dom-if" if="[[_isChangeMerged(change)]]">
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.MERGED_AS)]]"
+      >
+        <span class="title">Merged As</span>
+        <span class="value">
+          <gr-commit-info
+            change="[[change]]"
+            commit-info="[[_computeMergedCommitInfo(change.current_revision, change.revisions)]]"
+            server-config="[[serverConfig]]"
+          ></gr-commit-info>
+        </span>
+      </section>
+    </template>
+    <section
+      class$="topic [[_computeDisplayState(_showAllSections, change, _SECTION.TOPIC)]]"
+    >
       <span class="title">Topic</span>
       <span class="value">
         <template is="dom-if" if="[[_showTopicChip(change.*, _settingTopic)]]">
@@ -283,12 +381,17 @@
             placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
             read-only="[[_topicReadOnly]]"
             on-changed="_handleTopicChanged"
+            show-as-edit-pencil="true"
+            autocomplete="true"
+            query="[[queryTopic]]"
           ></gr-editable-label>
         </template>
       </span>
     </section>
     <template is="dom-if" if="[[_showCherryPickOf(change.*)]]">
-      <section>
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.CHERRY_PICK_OF)]]"
+      >
         <span class="title">Cherry pick of</span>
         <span class="value">
           <a
@@ -304,14 +407,16 @@
       </section>
     </template>
     <section
-      class="strategy"
+      class$="strategy [[_computeDisplayState(_showAllSections, change, _SECTION.STRATEGY)]]"
       hidden$="[[_computeHideStrategy(change)]]"
       hidden=""
     >
       <span class="title">Strategy</span>
       <span class="value">[[_computeStrategy(change)]]</span>
     </section>
-    <section class="hashtag">
+    <section
+      class$="hashtag [[_computeDisplayState(_showAllSections, change, _SECTION.HASHTAGS)]]"
+    >
       <span class="title">Hashtags</span>
       <span class="value">
         <template is="dom-repeat" items="[[change.hashtags]]">
@@ -333,12 +438,12 @@
             placeholder="[[_computeHashtagPlaceholder(_hashtagReadOnly)]]"
             read-only="[[_hashtagReadOnly]]"
             on-changed="_handleHashtagChanged"
+            show-as-edit-pencil="true"
           ></gr-editable-label>
         </template>
       </span>
     </section>
     <div class="separatedSection">
-      <h3 class="assistive-tech-only">Label Scores</h3>
       <gr-change-requirements
         change="{{change}}"
         account="[[account]]"
@@ -371,5 +476,4 @@
       ></gr-endpoint-param>
     </gr-endpoint-decorator>
   </gr-external-style>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
deleted file mode 100644
index 5bdf105..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
+++ /dev/null
@@ -1,782 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../../core/gr-router/gr-router.js';
-import './gr-change-metadata.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const basicFixture = fixtureFromElement('gr-change-metadata');
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-change-metadata tests', () => {
-  let element;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getLoggedIn() { return Promise.resolve(false); },
-    });
-
-    element = basicFixture.instantiate();
-  });
-
-  test('computed fields', () => {
-    assert.isFalse(element._computeHideStrategy({status: 'NEW'}));
-    assert.isTrue(element._computeHideStrategy({status: 'MERGED'}));
-    assert.isTrue(element._computeHideStrategy({status: 'ABANDONED'}));
-    assert.equal(element._computeStrategy({submit_type: 'CHERRY_PICK'}),
-        'Cherry Pick');
-    assert.equal(element._computeStrategy({submit_type: 'REBASE_ALWAYS'}),
-        'Rebase Always');
-  });
-
-  test('computed fields requirements', () => {
-    assert.isFalse(element._computeShowRequirements({status: 'MERGED'}));
-    assert.isFalse(element._computeShowRequirements({status: 'ABANDONED'}));
-
-    // No labels and no requirements: submit status is useless
-    assert.isFalse(element._computeShowRequirements({
-      status: 'NEW',
-      labels: {},
-    }));
-
-    // Work in Progress: submit status should be present
-    assert.isTrue(element._computeShowRequirements({
-      status: 'NEW',
-      labels: {},
-      work_in_progress: true,
-    }));
-
-    // We have at least one reason to display Submit Status
-    assert.isTrue(element._computeShowRequirements({
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: false,
-        },
-      },
-      requirements: [],
-    }));
-    assert.isTrue(element._computeShowRequirements({
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    }));
-  });
-
-  test('show strategy for open change', () => {
-    element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {}};
-    flush();
-    const strategy = element.shadowRoot
-        .querySelector('.strategy');
-    assert.ok(strategy);
-    assert.isFalse(strategy.hasAttribute('hidden'));
-    assert.equal(strategy.children[1].innerHTML, 'Cherry Pick');
-  });
-
-  test('hide strategy for closed change', () => {
-    element.change = {status: 'MERGED', labels: {}};
-    flush();
-    assert.isTrue(element.shadowRoot
-        .querySelector('.strategy').hasAttribute('hidden'));
-  });
-
-  test('weblinks use GerritNav interface', () => {
-    const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
-        .returns([{name: 'stubb', url: '#s'}]);
-    element.commitInfo = {};
-    element.serverConfig = {};
-    flush();
-    const webLinks = element.$.webLinks;
-    assert.isTrue(weblinksStub.called);
-    assert.isFalse(webLinks.hasAttribute('hidden'));
-    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
-  });
-
-  test('weblinks hidden when no weblinks', () => {
-    element.commitInfo = {};
-    element.serverConfig = {};
-    flush();
-    const webLinks = element.$.webLinks;
-    assert.isTrue(webLinks.hasAttribute('hidden'));
-  });
-
-  test('weblinks hidden when only gitiles weblink', () => {
-    element.commitInfo = {web_links: [{name: 'gitiles', url: '#'}]};
-    element.serverConfig = {};
-    flush();
-    const webLinks = element.$.webLinks;
-    assert.isTrue(webLinks.hasAttribute('hidden'));
-    assert.equal(element._computeWebLinks(element.commitInfo), null);
-  });
-
-  test('weblinks hidden when sole weblink is set as primary', () => {
-    const browser = 'browser';
-    element.commitInfo = {web_links: [{name: browser, url: '#'}]};
-    element.serverConfig = {
-      gerrit: {
-        primary_weblink_name: browser,
-      },
-    };
-    flush();
-    const webLinks = element.$.webLinks;
-    assert.isTrue(webLinks.hasAttribute('hidden'));
-  });
-
-  test('weblinks are visible when other weblinks', () => {
-    const router = document.createElement('gr-router');
-    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
-        router._generateWeblinks.bind(router));
-
-    element.commitInfo = {web_links: [{name: 'test', url: '#'}]};
-    flush();
-    const webLinks = element.$.webLinks;
-    assert.isFalse(webLinks.hasAttribute('hidden'));
-    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
-    // With two non-gitiles weblinks, there are two returned.
-    element.commitInfo = {
-      web_links: [{name: 'test', url: '#'}, {name: 'test2', url: '#'}]};
-    assert.equal(element._computeWebLinks(element.commitInfo).length, 2);
-  });
-
-  test('weblinks are visible when gitiles and other weblinks', () => {
-    const router = document.createElement('gr-router');
-    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
-        router._generateWeblinks.bind(router));
-
-    element.commitInfo = {
-      web_links: [{name: 'test', url: '#'}, {name: 'gitiles', url: '#'}]};
-    flush();
-    const webLinks = element.$.webLinks;
-    assert.isFalse(webLinks.hasAttribute('hidden'));
-    // Only the non-gitiles weblink is returned.
-    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
-  });
-
-  suite('_getNonOwnerRole', () => {
-    let change;
-
-    setup(() => {
-      change = {
-        owner: {
-          email: 'abc@def',
-          _account_id: 1019328,
-        },
-        revisions: {
-          rev1: {
-            _number: 1,
-            uploader: {
-              email: 'ghi@def',
-              _account_id: 1011123,
-            },
-            commit: {
-              author: {email: 'jkl@def'},
-              committer: {email: 'ghi@def'},
-            },
-          },
-        },
-        current_revision: 'rev1',
-      };
-    });
-
-    suite('role=uploader', () => {
-      test('_getNonOwnerRole for uploader', () => {
-        assert.deepEqual(
-            element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER),
-            {email: 'ghi@def', _account_id: 1011123});
-      });
-
-      test('_getNonOwnerRole that it does not return uploader', () => {
-        // Set the uploader email to be the same as the owner.
-        change.revisions.rev1.uploader._account_id = 1019328;
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.UPLOADER));
-      });
-
-      test('_getNonOwnerRole null for uploader with no current rev', () => {
-        delete change.current_revision;
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.UPLOADER));
-      });
-
-      test('_computeShowRoleClass show uploader', () => {
-        assert.equal(element._computeShowRoleClass(
-            change, element._CHANGE_ROLE.UPLOADER), '');
-      });
-
-      test('_computeShowRoleClass hide uploader', () => {
-        // Set the uploader email to be the same as the owner.
-        change.revisions.rev1.uploader._account_id = 1019328;
-        assert.equal(element._computeShowRoleClass(change,
-            element._CHANGE_ROLE.UPLOADER), 'hideDisplay');
-      });
-    });
-
-    suite('role=committer', () => {
-      test('_getNonOwnerRole for committer', () => {
-        change.revisions.rev1.uploader.email = 'ghh@def';
-        assert.deepEqual(
-            element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
-            {email: 'ghi@def'});
-      });
-
-      test('_getNonOwnerRole is null if committer is same as uploader', () => {
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.COMMITTER));
-      });
-
-      test('_getNonOwnerRole that it does not return committer', () => {
-        // Set the committer email to be the same as the owner.
-        change.revisions.rev1.commit.committer.email = 'abc@def';
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.COMMITTER));
-      });
-
-      test('_getNonOwnerRole null for committer with no current rev', () => {
-        delete change.current_revision;
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.COMMITTER));
-      });
-
-      test('_getNonOwnerRole null for committer with no commit', () => {
-        delete change.revisions.rev1.commit;
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.COMMITTER));
-      });
-
-      test('_getNonOwnerRole null for committer with no committer', () => {
-        delete change.revisions.rev1.commit.committer;
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.COMMITTER));
-      });
-    });
-
-    suite('role=author', () => {
-      test('_getNonOwnerRole for author', () => {
-        assert.deepEqual(
-            element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
-            {email: 'jkl@def'});
-      });
-
-      test('_getNonOwnerRole that it does not return author', () => {
-        // Set the author email to be the same as the owner.
-        change.revisions.rev1.commit.author.email = 'abc@def';
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.AUTHOR));
-      });
-
-      test('_getNonOwnerRole null for author with no current rev', () => {
-        delete change.current_revision;
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.AUTHOR));
-      });
-
-      test('_getNonOwnerRole null for author with no commit', () => {
-        delete change.revisions.rev1.commit;
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.AUTHOR));
-      });
-
-      test('_getNonOwnerRole null for author with no author', () => {
-        delete change.revisions.rev1.commit.author;
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.AUTHOR));
-      });
-    });
-  });
-
-  test('Push Certificate Validation test BAD', () => {
-    const serverConfig = {
-      receive: {
-        enable_signed_push: true,
-      },
-    };
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      owner: {
-        _account_id: 1019328,
-      },
-      revisions: {
-        rev1: {
-          _number: 1,
-          push_certificate: {
-            key: {
-              status: 'BAD',
-              problems: [
-                'No public keys found for key ID E5E20E52',
-              ],
-            },
-          },
-        },
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: true,
-    };
-    const result =
-        element._computePushCertificateValidation(serverConfig, change);
-    assert.equal(result.message,
-        'Push certificate is invalid:\n' +
-        'No public keys found for key ID E5E20E52');
-    assert.equal(result.icon, 'gr-icons:close');
-    assert.equal(result.class, 'invalid');
-  });
-
-  test('Push Certificate Validation test TRUSTED', () => {
-    const serverConfig = {
-      receive: {
-        enable_signed_push: true,
-      },
-    };
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      owner: {
-        _account_id: 1019328,
-      },
-      revisions: {
-        rev1: {
-          _number: 1,
-          push_certificate: {
-            key: {
-              status: 'TRUSTED',
-            },
-          },
-        },
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: true,
-    };
-    const result =
-        element._computePushCertificateValidation(serverConfig, change);
-    assert.equal(result.message,
-        'Push certificate is valid and key is trusted');
-    assert.equal(result.icon, 'gr-icons:check');
-    assert.equal(result.class, 'trusted');
-  });
-
-  test('Push Certificate Validation is missing test', () => {
-    const serverConfig = {
-      receive: {
-        enable_signed_push: true,
-      },
-    };
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      owner: {
-        _account_id: 1019328,
-      },
-      revisions: {
-        rev1: {
-          _number: 1,
-        },
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: true,
-    };
-    const result =
-        element._computePushCertificateValidation(serverConfig, change);
-    assert.equal(result.message,
-        'This patch set was created without a push certificate');
-    assert.equal(result.icon, 'gr-icons:help');
-    assert.equal(result.class, 'help');
-  });
-
-  test('_computeParents', () => {
-    const parents = [{commit: '123', subject: 'abc'}];
-    const revision = {commit: {parents}};
-    assert.deepEqual(element._computeParents({}, {}), []);
-    assert.equal(element._computeParents(null, revision), parents);
-    const change = current_revision => {
-      return {current_revision, revisions: {456: revision}};
-    };
-    assert.deepEqual(element._computeParents(change(null), null), []);
-    const change_bad_revision = change('789');
-    assert.deepEqual(element._computeParents(change_bad_revision, {}), []);
-    const change_no_commit = {current_revision: '456', revisions: {456: {}}};
-    assert.deepEqual(element._computeParents(change_no_commit, null), []);
-    const change_good = change('456');
-    assert.equal(element._computeParents(change_good, null), parents);
-  });
-
-  test('_currentParents', () => {
-    const revision = parent => {
-      return {commit: {parents: [{commit: parent, subject: 'abc'}]}};
-    };
-    element.change = {
-      current_revision: '456',
-      revisions: {456: revision('111')},
-      owner: {},
-    };
-    element.revision = revision('222');
-    assert.equal(element._currentParents[0].commit, '222');
-    element.revision = revision('333');
-    assert.equal(element._currentParents[0].commit, '333');
-    element.revision = null;
-    assert.equal(element._currentParents[0].commit, '111');
-    element.change = {current_revision: null};
-    assert.deepEqual(element._currentParents, []);
-  });
-
-  test('_computeParentsLabel', () => {
-    const parent = {commit: 'abc123', subject: 'My parent commit'};
-    assert.equal(element._computeParentsLabel([parent]), 'Parent');
-    assert.equal(element._computeParentsLabel([parent, parent]),
-        'Parents');
-  });
-
-  test('_computeParentListClass', () => {
-    const parent = {commit: 'abc123', subject: 'My parent commit'};
-    assert.equal(element._computeParentListClass([parent], true),
-        'parentList nonMerge current');
-    assert.equal(element._computeParentListClass([parent], false),
-        'parentList nonMerge notCurrent');
-    assert.equal(element._computeParentListClass([parent, parent], false),
-        'parentList merge notCurrent');
-    assert.equal(element._computeParentListClass([parent, parent], true),
-        'parentList merge current');
-  });
-
-  test('_showAddTopic', () => {
-    assert.isTrue(element._showAddTopic(null, false));
-    assert.isTrue(element._showAddTopic({base: {topic: null}}, false));
-    assert.isFalse(element._showAddTopic({base: {topic: null}}, true));
-    assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, true));
-    assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, false));
-  });
-
-  test('_showTopicChip', () => {
-    assert.isFalse(element._showTopicChip(null, false));
-    assert.isFalse(element._showTopicChip({base: {topic: null}}, false));
-    assert.isFalse(element._showTopicChip({base: {topic: null}}, true));
-    assert.isFalse(element._showTopicChip({base: {topic: 'foo'}}, true));
-    assert.isTrue(element._showTopicChip({base: {topic: 'foo'}}, false));
-  });
-
-  test('_showCherryPickOf', () => {
-    assert.isFalse(element._showCherryPickOf(null));
-    assert.isFalse(element._showCherryPickOf({
-      base: {
-        cherry_pick_of_change: null,
-        cherry_pick_of_patch_set: null,
-      },
-    }));
-    assert.isTrue(element._showCherryPickOf({
-      base: {
-        cherry_pick_of_change: 123,
-        cherry_pick_of_patch_set: 1,
-      },
-    }));
-  });
-
-  suite('Topic removal', () => {
-    let change;
-    setup(() => {
-      change = {
-        _number: 'the number',
-        actions: {
-          topic: {enabled: false},
-        },
-        change_id: 'the id',
-        topic: 'the topic',
-        status: 'NEW',
-        submit_type: 'CHERRY_PICK',
-        labels: {
-          test: {
-            all: [{_account_id: 1, name: 'bojack', value: 1}],
-            default_value: 0,
-            values: [],
-          },
-        },
-        removable_reviewers: [],
-      };
-    });
-
-    test('_computeTopicReadOnly', () => {
-      let mutable = false;
-      assert.isTrue(element._computeTopicReadOnly(mutable, change));
-      mutable = true;
-      assert.isTrue(element._computeTopicReadOnly(mutable, change));
-      change.actions.topic.enabled = true;
-      assert.isFalse(element._computeTopicReadOnly(mutable, change));
-      mutable = false;
-      assert.isTrue(element._computeTopicReadOnly(mutable, change));
-    });
-
-    test('topic read only hides delete button', () => {
-      element.account = {};
-      element.change = change;
-      flush();
-      const button = element.shadowRoot
-          .querySelector('gr-linked-chip').shadowRoot
-          .querySelector('gr-button');
-      assert.isTrue(button.hasAttribute('hidden'));
-    });
-
-    test('topic not read only does not hide delete button', () => {
-      element.account = {test: true};
-      change.actions.topic.enabled = true;
-      element.change = change;
-      flush();
-      const button = element.shadowRoot
-          .querySelector('gr-linked-chip').shadowRoot
-          .querySelector('gr-button');
-      assert.isFalse(button.hasAttribute('hidden'));
-    });
-  });
-
-  suite('Hashtag removal', () => {
-    let change;
-    setup(() => {
-      change = {
-        _number: 'the number',
-        actions: {
-          hashtags: {enabled: false},
-        },
-        change_id: 'the id',
-        hashtags: ['test-hashtag'],
-        status: 'NEW',
-        submit_type: 'CHERRY_PICK',
-        labels: {
-          test: {
-            all: [{_account_id: 1, name: 'bojack', value: 1}],
-            default_value: 0,
-            values: [],
-          },
-        },
-        removable_reviewers: [],
-      };
-    });
-
-    test('_computeHashtagReadOnly', () => {
-      flush();
-      let mutable = false;
-      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
-      mutable = true;
-      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
-      change.actions.hashtags.enabled = true;
-      assert.isFalse(element._computeHashtagReadOnly(mutable, change));
-      mutable = false;
-      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
-    });
-
-    test('hashtag read only hides delete button', () => {
-      flush();
-      element.account = {};
-      element.change = change;
-      flush();
-      const button = element.shadowRoot
-          .querySelector('gr-linked-chip').shadowRoot
-          .querySelector('gr-button');
-      assert.isTrue(button.hasAttribute('hidden'));
-    });
-
-    test('hashtag not read only does not hide delete button', () => {
-      flush();
-      element.account = {test: true};
-      change.actions.hashtags.enabled = true;
-      element.change = change;
-      flush();
-      const button = element.shadowRoot
-          .querySelector('gr-linked-chip').shadowRoot
-          .querySelector('gr-button');
-      assert.isFalse(button.hasAttribute('hidden'));
-    });
-  });
-
-  suite('remove reviewer votes', () => {
-    setup(() => {
-      sinon.stub(element, '_computeTopicReadOnly').returns(true);
-      element.change = {
-        _number: 42,
-        change_id: 'the id',
-        actions: [],
-        topic: 'the topic',
-        status: 'NEW',
-        submit_type: 'CHERRY_PICK',
-        labels: {
-          test: {
-            all: [{_account_id: 1, name: 'bojack', value: 1}],
-            default_value: 0,
-            values: [],
-          },
-        },
-        removable_reviewers: [],
-      };
-      flush();
-    });
-
-    suite('assignee field', () => {
-      const dummyAccount = {
-        _account_id: 1,
-        name: 'bojack',
-      };
-      const change = {
-        actions: {
-          assignee: {enabled: false},
-        },
-        assignee: dummyAccount,
-      };
-      let deleteStub;
-      let setStub;
-
-      setup(() => {
-        deleteStub = sinon.stub(element.$.restAPI, 'deleteAssignee');
-        setStub = sinon.stub(element.$.restAPI, 'setAssignee');
-        element.serverConfig = {
-          change: {
-            enable_assignee: true,
-          },
-        };
-      });
-
-      test('changing change recomputes _assignee', () => {
-        assert.isFalse(!!element._assignee.length);
-        const change = element.change;
-        change.assignee = dummyAccount;
-        element._changeChanged(change);
-        assert.deepEqual(element._assignee[0], dummyAccount);
-      });
-
-      test('modifying _assignee calls API', () => {
-        assert.isFalse(!!element._assignee.length);
-        element.set('_assignee', [dummyAccount]);
-        assert.isTrue(setStub.calledOnce);
-        assert.deepEqual(element.change.assignee, dummyAccount);
-        element.set('_assignee', [dummyAccount]);
-        assert.isTrue(setStub.calledOnce);
-        element.set('_assignee', []);
-        assert.isTrue(deleteStub.calledOnce);
-        assert.equal(element.change.assignee, undefined);
-        element.set('_assignee', []);
-        assert.isTrue(deleteStub.calledOnce);
-      });
-
-      test('_computeAssigneeReadOnly', () => {
-        let mutable = false;
-        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-        mutable = true;
-        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-        change.actions.assignee.enabled = true;
-        assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
-        mutable = false;
-        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-      });
-    });
-
-    test('changing topic', () => {
-      const newTopic = 'the new topic';
-      sinon.stub(element.$.restAPI, 'setChangeTopic').returns(
-          Promise.resolve(newTopic));
-      element._handleTopicChanged({detail: newTopic});
-      const topicChangedSpy = sinon.spy();
-      element.addEventListener('topic-changed', topicChangedSpy);
-      assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
-          42, newTopic));
-      return element.$.restAPI.setChangeTopic.lastCall.returnValue
-          .then(() => {
-            assert.equal(element.change.topic, newTopic);
-            assert.isTrue(topicChangedSpy.called);
-          });
-    });
-
-    test('topic removal', () => {
-      sinon.stub(element.$.restAPI, 'setChangeTopic').returns(
-          Promise.resolve());
-      const chip = element.shadowRoot
-          .querySelector('gr-linked-chip');
-      const remove = chip.$.remove;
-      const topicChangedSpy = sinon.spy();
-      element.addEventListener('topic-changed', topicChangedSpy);
-      MockInteractions.tap(remove);
-      assert.isTrue(chip.disabled);
-      assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
-          42, null));
-      return element.$.restAPI.setChangeTopic.lastCall.returnValue
-          .then(() => {
-            assert.isFalse(chip.disabled);
-            assert.equal(element.change.topic, '');
-            assert.isTrue(topicChangedSpy.called);
-          });
-    });
-
-    test('changing hashtag', () => {
-      flush();
-      element._newHashtag = 'new hashtag';
-      const newHashtag = ['new hashtag'];
-      sinon.stub(element.$.restAPI, 'setChangeHashtag').returns(
-          Promise.resolve(newHashtag));
-      element._handleHashtagChanged({}, 'new hashtag');
-      assert.isTrue(element.$.restAPI.setChangeHashtag.calledWith(
-          42, {add: ['new hashtag']}));
-      return element.$.restAPI.setChangeHashtag.lastCall.returnValue
-          .then(() => {
-            assert.equal(element.change.hashtags, newHashtag);
-          });
-    });
-  });
-
-  test('editTopic', () => {
-    element.account = {test: true};
-    element.change = {actions: {topic: {enabled: true}}};
-    flush();
-
-    const label = element.shadowRoot
-        .querySelector('.topicEditableLabel');
-    assert.ok(label);
-    sinon.stub(label, 'open');
-    element.editTopic();
-    flush();
-
-    assert.isTrue(label.open.called);
-  });
-
-  suite('plugin endpoints', () => {
-    test('endpoint params', done => {
-      element.change = {labels: {}};
-      element.revision = {};
-      let hookEl;
-      let plugin;
-      pluginApi.install(
-          p => {
-            plugin = p;
-            plugin.hook('change-metadata-item').getLastAttached()
-                .then(el => hookEl = el);
-          },
-          '0.1',
-          'http://some/plugins/url.html');
-      getPluginLoader().loadPlugins([]);
-      flush(() => {
-        assert.strictEqual(hookEl.plugin, plugin);
-        assert.strictEqual(hookEl.change, element.change);
-        assert.strictEqual(hookEl.revision, element.revision);
-        done();
-      });
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
new file mode 100644
index 0000000..ab8e404
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -0,0 +1,972 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import '../../core/gr-router/gr-router';
+import './gr-change-metadata';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
+import {GrChangeMetadata} from './gr-change-metadata';
+import {
+  createServerInfo,
+  createUserConfig,
+  createParsedChange,
+  createAccountWithId,
+  createRequirement,
+  createCommitInfoWithRequiredCommit,
+  createWebLinkInfo,
+  createGerritInfo,
+  createGitPerson,
+  createCommit,
+  createRevision,
+  createAccountDetailWithId,
+  createChangeConfig,
+} from '../../../test/test-data-generators';
+import {
+  ChangeStatus,
+  SubmitType,
+  RequirementStatus,
+  GpgKeyInfoStatus,
+} from '../../../constants/constants';
+import {
+  EmailAddress,
+  AccountId,
+  CommitId,
+  ServerInfo,
+  RevisionInfo,
+  ParentCommitInfo,
+  TopicName,
+  ElementPropertyDeepChange,
+  PatchSetNum,
+  NumericChangeId,
+  LabelValueToDescriptionMap,
+  Hashtag,
+} from '../../../types/common';
+import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
+import {PluginApi} from '../../../api/plugin';
+import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import {stubRestApi} from '../../../test/test-utils';
+import {ParsedChangeInfo} from '../../../types/types';
+
+const basicFixture = fixtureFromElement('gr-change-metadata');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-change-metadata tests', () => {
+  let element: GrChangeMetadata;
+
+  setup(() => {
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getConfig').returns(
+      Promise.resolve({
+        ...createServerInfo(),
+        user: {
+          ...createUserConfig(),
+          anonymous_coward_name: 'test coward name',
+        },
+      })
+    );
+    element = basicFixture.instantiate();
+  });
+
+  test('_computeMergedCommitInfo', () => {
+    const dummyRevs: {[revisionId: string]: RevisionInfo} = {
+      1: createRevision(1),
+      2: createRevision(2),
+    };
+    assert.deepEqual(
+      element._computeMergedCommitInfo('0' as CommitId, dummyRevs),
+      {}
+    );
+    assert.deepEqual(
+      element._computeMergedCommitInfo('1' as CommitId, dummyRevs),
+      dummyRevs[1].commit
+    );
+
+    // Regression test for issue 5337.
+    const commit = element._computeMergedCommitInfo('2' as CommitId, dummyRevs);
+    assert.notDeepEqual(commit, dummyRevs[2]);
+    assert.deepEqual(commit, dummyRevs[2].commit);
+  });
+
+  test('computed fields', () => {
+    assert.isFalse(
+      element._computeHideStrategy({
+        ...createParsedChange(),
+        status: ChangeStatus.NEW,
+      })
+    );
+    assert.isTrue(
+      element._computeHideStrategy({
+        ...createParsedChange(),
+        status: ChangeStatus.MERGED,
+      })
+    );
+    assert.isTrue(
+      element._computeHideStrategy({
+        ...createParsedChange(),
+        status: ChangeStatus.ABANDONED,
+      })
+    );
+    assert.equal(
+      element._computeStrategy({
+        ...createParsedChange(),
+        submit_type: SubmitType.CHERRY_PICK,
+      }),
+      'Cherry Pick'
+    );
+    assert.equal(
+      element._computeStrategy({
+        ...createParsedChange(),
+        submit_type: SubmitType.REBASE_ALWAYS,
+      }),
+      'Rebase Always'
+    );
+  });
+
+  test('computed fields requirements', () => {
+    assert.isFalse(
+      element._computeShowRequirements({
+        ...createParsedChange(),
+        status: ChangeStatus.MERGED,
+      })
+    );
+    assert.isFalse(
+      element._computeShowRequirements({
+        ...createParsedChange(),
+        status: ChangeStatus.ABANDONED,
+      })
+    );
+
+    // No labels and no requirements: submit status is useless
+    assert.isFalse(
+      element._computeShowRequirements({
+        ...createParsedChange(),
+        status: ChangeStatus.NEW,
+        labels: {},
+      })
+    );
+
+    // Work in Progress: submit status should be present
+    assert.isTrue(
+      element._computeShowRequirements({
+        ...createParsedChange(),
+        status: ChangeStatus.NEW,
+        labels: {},
+        work_in_progress: true,
+      })
+    );
+
+    // We have at least one reason to display Submit Status
+    assert.isTrue(
+      element._computeShowRequirements({
+        ...createParsedChange(),
+        status: ChangeStatus.NEW,
+        labels: {
+          Verified: {
+            approved: createAccountWithId(),
+          },
+        },
+        requirements: [],
+      })
+    );
+    assert.isTrue(
+      element._computeShowRequirements({
+        ...createParsedChange(),
+        status: ChangeStatus.NEW,
+        labels: {},
+        requirements: [
+          {
+            ...createRequirement(),
+            fallbackText: 'Resolve all comments',
+            status: RequirementStatus.OK,
+          },
+        ],
+      })
+    );
+  });
+
+  test('show strategy for open change', () => {
+    element.change = {
+      ...createParsedChange(),
+      status: ChangeStatus.NEW,
+      submit_type: SubmitType.CHERRY_PICK,
+      labels: {},
+    };
+    flush();
+    const strategy = element.shadowRoot?.querySelector('.strategy');
+    assert.ok(strategy);
+    assert.isFalse(strategy?.hasAttribute('hidden'));
+    assert.equal(strategy?.children[1].innerHTML, 'Cherry Pick');
+  });
+
+  test('hide strategy for closed change', () => {
+    element.change = {
+      ...createParsedChange(),
+      status: ChangeStatus.MERGED,
+      labels: {},
+    };
+    flush();
+    assert.isTrue(
+      element.shadowRoot?.querySelector('.strategy')?.hasAttribute('hidden')
+    );
+  });
+
+  test('weblinks use GerritNav interface', () => {
+    const weblinksStub = sinon
+      .stub(GerritNav, '_generateWeblinks')
+      .returns([{name: 'stubb', url: '#s'}]);
+    element.commitInfo = createCommitInfoWithRequiredCommit();
+    element.serverConfig = createServerInfo();
+    flush();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(weblinksStub.called);
+    assert.isFalse(webLinks.hasAttribute('hidden'));
+    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 1);
+  });
+
+  test('weblinks hidden when no weblinks', () => {
+    element.commitInfo = createCommitInfoWithRequiredCommit();
+    element.serverConfig = createServerInfo();
+    flush();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(webLinks.hasAttribute('hidden'));
+  });
+
+  test('weblinks hidden when only gitiles weblink', () => {
+    element.commitInfo = {
+      ...createCommitInfoWithRequiredCommit(),
+      web_links: [{...createWebLinkInfo(), name: 'gitiles', url: '#'}],
+    };
+    element.serverConfig = createServerInfo();
+    flush();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(webLinks.hasAttribute('hidden'));
+    assert.equal(element._computeWebLinks(element.commitInfo), null);
+  });
+
+  test('weblinks hidden when sole weblink is set as primary', () => {
+    const browser = 'browser';
+    element.commitInfo = {
+      ...createCommitInfoWithRequiredCommit(),
+      web_links: [{...createWebLinkInfo(), name: browser, url: '#'}],
+    };
+    element.serverConfig = {
+      ...createServerInfo(),
+      gerrit: {
+        ...createGerritInfo(),
+        primary_weblink_name: browser,
+      },
+    };
+    flush();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(webLinks.hasAttribute('hidden'));
+  });
+
+  test('weblinks are visible when other weblinks', () => {
+    const router = document.createElement('gr-router');
+    sinon
+      .stub(GerritNav, '_generateWeblinks')
+      .callsFake(router._generateWeblinks.bind(router));
+
+    element.commitInfo = {
+      ...createCommitInfoWithRequiredCommit(),
+      web_links: [{...createWebLinkInfo(), name: 'test', url: '#'}],
+    };
+    flush();
+    const webLinks = element.$.webLinks;
+    assert.isFalse(webLinks.hasAttribute('hidden'));
+    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 1);
+    // With two non-gitiles weblinks, there are two returned.
+    element.commitInfo = {
+      ...createCommitInfoWithRequiredCommit(),
+      web_links: [
+        {...createWebLinkInfo(), name: 'test', url: '#'},
+        {...createWebLinkInfo(), name: 'test2', url: '#'},
+      ],
+    };
+    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 2);
+  });
+
+  test('weblinks are visible when gitiles and other weblinks', () => {
+    const router = document.createElement('gr-router');
+    sinon
+      .stub(GerritNav, '_generateWeblinks')
+      .callsFake(router._generateWeblinks.bind(router));
+
+    element.commitInfo = {
+      ...createCommitInfoWithRequiredCommit(),
+      web_links: [
+        {...createWebLinkInfo(), name: 'test', url: '#'},
+        {...createWebLinkInfo(), name: 'gitiles', url: '#'},
+      ],
+    };
+    flush();
+    const webLinks = element.$.webLinks;
+    assert.isFalse(webLinks.hasAttribute('hidden'));
+    // Only the non-gitiles weblink is returned.
+    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 1);
+  });
+
+  suite('_getNonOwnerRole', () => {
+    let change: ParsedChangeInfo | undefined;
+
+    setup(() => {
+      change = {
+        ...createParsedChange(),
+        owner: {
+          ...createAccountWithId(),
+          email: 'abc@def' as EmailAddress,
+          _account_id: 1019328 as AccountId,
+        },
+        revisions: {
+          rev1: {
+            ...createRevision(),
+            uploader: {
+              ...createAccountWithId(),
+              email: 'ghi@def' as EmailAddress,
+              _account_id: 1011123 as AccountId,
+            },
+            commit: {
+              ...createCommit(),
+              author: {...createGitPerson(), email: 'jkl@def'},
+              committer: {...createGitPerson(), email: 'ghi@def'},
+            },
+          },
+        },
+        current_revision: 'rev1' as CommitId,
+      };
+    });
+
+    suite('role=uploader', () => {
+      test('_getNonOwnerRole for uploader', () => {
+        assert.deepEqual(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER),
+          {
+            ...createAccountWithId(),
+            email: 'ghi@def' as EmailAddress,
+            _account_id: 1011123 as AccountId,
+          }
+        );
+      });
+
+      test('_getNonOwnerRole that it does not return uploader', () => {
+        // Set the uploader email to be the same as the owner.
+        change!.revisions.rev1.uploader!._account_id = 1019328 as AccountId;
+        assert.isNotOk(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER)
+        );
+      });
+
+      test('_computeShowRoleClass show uploader', () => {
+        assert.equal(
+          element._computeShowRoleClass(change, element._CHANGE_ROLE.UPLOADER),
+          ''
+        );
+      });
+
+      test('_computeShowRoleClass hide uploader', () => {
+        // Set the uploader email to be the same as the owner.
+        change!.revisions.rev1.uploader!._account_id = 1019328 as AccountId;
+        assert.equal(
+          element._computeShowRoleClass(change, element._CHANGE_ROLE.UPLOADER),
+          'hideDisplay'
+        );
+      });
+    });
+
+    suite('role=committer', () => {
+      test('_getNonOwnerRole for committer', () => {
+        change!.revisions.rev1.uploader!.email = 'ghh@def' as EmailAddress;
+        assert.deepEqual(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
+          {...createGitPerson(), email: 'ghi@def'}
+        );
+      });
+
+      test('_getNonOwnerRole is null if committer is same as uploader', () => {
+        assert.isNotOk(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER)
+        );
+      });
+
+      test('_getNonOwnerRole that it does not return committer', () => {
+        // Set the committer email to be the same as the owner.
+        change!.revisions.rev1.commit!.committer.email = 'abc@def';
+        assert.isNotOk(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER)
+        );
+      });
+
+      test('_getNonOwnerRole null for committer with no commit', () => {
+        delete change!.revisions.rev1.commit;
+        assert.isNotOk(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER)
+        );
+      });
+    });
+
+    suite('role=author', () => {
+      test('_getNonOwnerRole for author', () => {
+        assert.deepEqual(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
+          {...createGitPerson(), email: 'jkl@def'}
+        );
+      });
+
+      test('_getNonOwnerRole that it does not return author', () => {
+        // Set the author email to be the same as the owner.
+        change!.revisions.rev1.commit!.author.email = 'abc@def';
+        assert.isNotOk(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR)
+        );
+      });
+
+      test('_getNonOwnerRole null for author with no commit', () => {
+        delete change!.revisions.rev1.commit;
+        assert.isNotOk(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR)
+        );
+      });
+    });
+  });
+
+  suite('Push Certificate Validation', () => {
+    let serverConfig: ServerInfo | undefined;
+    let change: ParsedChangeInfo | undefined;
+
+    setup(() => {
+      serverConfig = {
+        ...createServerInfo(),
+        receive: {
+          enable_signed_push: 'true',
+        },
+      };
+      change = {
+        ...createParsedChange(),
+        revisions: {
+          rev1: {
+            ...createRevision(1),
+            push_certificate: {
+              certificate: 'Push certificate',
+              key: {
+                status: GpgKeyInfoStatus.BAD,
+                problems: ['No public keys found for key ID E5E20E52'],
+              },
+            },
+          },
+        },
+        current_revision: 'rev1' as CommitId,
+        status: ChangeStatus.NEW,
+        labels: {},
+        mergeable: true,
+      };
+    });
+
+    test('Push Certificate Validation test BAD', () => {
+      change!.revisions.rev1!.push_certificate = {
+        certificate: 'Push certificate',
+        key: {
+          status: GpgKeyInfoStatus.BAD,
+          problems: ['No public keys found for key ID E5E20E52'],
+        },
+      };
+      const result = element._computePushCertificateValidation(
+        serverConfig,
+        change
+      );
+      assert.equal(
+        result?.message,
+        'Push certificate is invalid:\n' +
+          'No public keys found for key ID E5E20E52'
+      );
+      assert.equal(result?.icon, 'gr-icons:close');
+      assert.equal(result?.class, 'invalid');
+    });
+
+    test('Push Certificate Validation test TRUSTED', () => {
+      change!.revisions.rev1!.push_certificate = {
+        certificate: 'Push certificate',
+        key: {
+          status: GpgKeyInfoStatus.TRUSTED,
+        },
+      };
+      const result = element._computePushCertificateValidation(
+        serverConfig,
+        change
+      );
+      assert.equal(
+        result?.message,
+        'Push certificate is valid and key is trusted'
+      );
+      assert.equal(result?.icon, 'gr-icons:check');
+      assert.equal(result?.class, 'trusted');
+    });
+
+    test('Push Certificate Validation is missing test', () => {
+      change!.revisions.rev1! = createRevision(1);
+      const result = element._computePushCertificateValidation(
+        serverConfig,
+        change
+      );
+      assert.equal(
+        result?.message,
+        'This patch set was created without a push certificate'
+      );
+      assert.equal(result?.icon, 'gr-icons:help');
+      assert.equal(result?.class, 'help');
+    });
+  });
+
+  test('_computeParents', () => {
+    const parents: ParentCommitInfo[] = [
+      {...createCommit(), commit: '123' as CommitId, subject: 'abc'},
+    ];
+    const revision: RevisionInfo = {
+      ...createRevision(1),
+      commit: {...createCommit(), parents},
+    };
+    assert.equal(element._computeParents(undefined, revision), parents);
+    const change = (current_revision: CommitId): ParsedChangeInfo => {
+      return {
+        ...createParsedChange(),
+        current_revision,
+        revisions: {456: revision},
+      };
+    };
+    const change_bad_revision = change('789' as CommitId);
+    assert.deepEqual(
+      element._computeParents(change_bad_revision, createRevision()),
+      []
+    );
+    const change_no_commit: ParsedChangeInfo = {
+      ...createParsedChange(),
+      current_revision: '456' as CommitId,
+      revisions: {456: createRevision()},
+    };
+    assert.deepEqual(element._computeParents(change_no_commit, undefined), []);
+    const change_good = change('456' as CommitId);
+    assert.equal(element._computeParents(change_good, undefined), parents);
+  });
+
+  test('_currentParents', () => {
+    const revision = (parent: CommitId): RevisionInfo => {
+      return {
+        ...createRevision(),
+        commit: {
+          ...createCommit(),
+          parents: [{...createCommit(), commit: parent, subject: 'abc'}],
+        },
+      };
+    };
+    element.change = {
+      ...createParsedChange(),
+      current_revision: '456' as CommitId,
+      revisions: {456: revision('111' as CommitId)},
+      owner: {},
+    };
+    element.revision = revision('222' as CommitId);
+    assert.equal(element._currentParents[0].commit, '222');
+    element.revision = revision('333' as CommitId);
+    assert.equal(element._currentParents[0].commit, '333');
+    element.revision = undefined;
+    assert.equal(element._currentParents[0].commit, '111');
+    element.change = createParsedChange();
+    assert.deepEqual(element._currentParents, []);
+  });
+
+  test('_computeParentsLabel', () => {
+    const parent: ParentCommitInfo = {
+      ...createCommit(),
+      commit: 'abc123' as CommitId,
+      subject: 'My parent commit',
+    };
+    assert.equal(element._computeParentsLabel([parent]), 'Parent');
+    assert.equal(element._computeParentsLabel([parent, parent]), 'Parents');
+  });
+
+  test('_computeParentListClass', () => {
+    const parent: ParentCommitInfo = {
+      ...createCommit(),
+      commit: 'abc123' as CommitId,
+      subject: 'My parent commit',
+    };
+    assert.equal(
+      element._computeParentListClass([parent], true),
+      'parentList nonMerge current'
+    );
+    assert.equal(
+      element._computeParentListClass([parent], false),
+      'parentList nonMerge notCurrent'
+    );
+    assert.equal(
+      element._computeParentListClass([parent, parent], false),
+      'parentList merge notCurrent'
+    );
+    assert.equal(
+      element._computeParentListClass([parent, parent], true),
+      'parentList merge current'
+    );
+  });
+
+  test('_showAddTopic', () => {
+    const changeRecord: ElementPropertyDeepChange<
+      GrChangeMetadata,
+      'change'
+    > = {
+      base: {...createParsedChange()},
+      path: '',
+      value: undefined,
+    };
+    assert.isTrue(element._showAddTopic(undefined, false));
+    assert.isTrue(element._showAddTopic(changeRecord, false));
+    assert.isFalse(element._showAddTopic(changeRecord, true));
+    changeRecord.base!.topic = 'foo' as TopicName;
+    assert.isFalse(element._showAddTopic(changeRecord, true));
+    assert.isFalse(element._showAddTopic(changeRecord, false));
+  });
+
+  test('_showTopicChip', () => {
+    const changeRecord: ElementPropertyDeepChange<
+      GrChangeMetadata,
+      'change'
+    > = {
+      base: {...createParsedChange()},
+      path: '',
+      value: undefined,
+    };
+    assert.isFalse(element._showTopicChip(undefined, false));
+    assert.isFalse(element._showTopicChip(changeRecord, false));
+    assert.isFalse(element._showTopicChip(changeRecord, true));
+    changeRecord.base!.topic = 'foo' as TopicName;
+    assert.isFalse(element._showTopicChip(changeRecord, true));
+    assert.isTrue(element._showTopicChip(changeRecord, false));
+  });
+
+  test('_showCherryPickOf', () => {
+    const changeRecord: ElementPropertyDeepChange<
+      GrChangeMetadata,
+      'change'
+    > = {
+      base: {...createParsedChange()},
+      path: '',
+      value: undefined,
+    };
+    assert.isFalse(element._showCherryPickOf(undefined));
+    assert.isFalse(element._showCherryPickOf(changeRecord));
+    changeRecord.base!.cherry_pick_of_change = 123 as NumericChangeId;
+    changeRecord.base!.cherry_pick_of_patch_set = 1 as PatchSetNum;
+    assert.isTrue(element._showCherryPickOf(changeRecord));
+  });
+
+  suite('Topic removal', () => {
+    let change: ParsedChangeInfo;
+    setup(() => {
+      change = {
+        ...createParsedChange(),
+        actions: {
+          topic: {enabled: false},
+        },
+        topic: 'the topic' as TopicName,
+        status: ChangeStatus.NEW,
+        submit_type: SubmitType.CHERRY_PICK,
+        labels: {
+          test: {
+            all: [{_account_id: 1 as AccountId, name: 'bojack', value: 1}],
+            default_value: 0,
+            values: ([] as unknown) as LabelValueToDescriptionMap,
+          },
+        },
+        removable_reviewers: [],
+      };
+    });
+
+    test('_computeTopicReadOnly', () => {
+      let mutable = false;
+      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      mutable = true;
+      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      change!.actions!.topic!.enabled = true;
+      assert.isFalse(element._computeTopicReadOnly(mutable, change));
+      mutable = false;
+      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+    });
+
+    test('topic read only hides delete button', () => {
+      element.account = createAccountDetailWithId();
+      element.change = change;
+      flush();
+      const button = element!
+        .shadowRoot!.querySelector('gr-linked-chip')!
+        .shadowRoot!.querySelector('gr-button');
+      assert.isTrue(button?.hasAttribute('hidden'));
+    });
+
+    test('topic not read only does not hide delete button', () => {
+      element.account = createAccountDetailWithId();
+      change.actions!.topic!.enabled = true;
+      element.change = change;
+      flush();
+      const button = element!
+        .shadowRoot!.querySelector('gr-linked-chip')!
+        .shadowRoot!.querySelector('gr-button');
+      assert.isFalse(button?.hasAttribute('hidden'));
+    });
+  });
+
+  suite('Hashtag removal', () => {
+    let change: ParsedChangeInfo;
+    setup(() => {
+      change = {
+        ...createParsedChange(),
+        actions: {
+          hashtags: {enabled: false},
+        },
+        hashtags: ['test-hashtag' as Hashtag],
+        labels: {
+          test: {
+            all: [{_account_id: 1 as AccountId, name: 'bojack', value: 1}],
+            default_value: 0,
+            values: ([] as unknown) as LabelValueToDescriptionMap,
+          },
+        },
+        removable_reviewers: [],
+      };
+    });
+
+    test('_computeHashtagReadOnly', () => {
+      flush();
+      let mutable = false;
+      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      mutable = true;
+      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      change!.actions!.hashtags!.enabled = true;
+      assert.isFalse(element._computeHashtagReadOnly(mutable, change));
+      mutable = false;
+      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+    });
+
+    test('hashtag read only hides delete button', () => {
+      flush();
+      element.account = createAccountDetailWithId();
+      element.change = change;
+      flush();
+      const button = element!
+        .shadowRoot!.querySelector('gr-linked-chip')!
+        .shadowRoot!.querySelector('gr-button');
+      assert.isTrue(button?.hasAttribute('hidden'));
+    });
+
+    test('hashtag not read only does not hide delete button', () => {
+      flush();
+      element.account = createAccountDetailWithId();
+      change!.actions!.hashtags!.enabled = true;
+      element.change = change;
+      flush();
+      const button = element!
+        .shadowRoot!.querySelector('gr-linked-chip')!
+        .shadowRoot!.querySelector('gr-button');
+      assert.isFalse(button?.hasAttribute('hidden'));
+    });
+  });
+
+  suite('remove reviewer votes', () => {
+    setup(() => {
+      sinon.stub(element, '_computeTopicReadOnly').returns(true);
+      element.change = {
+        ...createParsedChange(),
+        topic: 'the topic' as TopicName,
+        labels: {
+          test: {
+            all: [{_account_id: 1 as AccountId, name: 'bojack', value: 1}],
+            default_value: 0,
+            values: ([] as unknown) as LabelValueToDescriptionMap,
+          },
+        },
+        removable_reviewers: [],
+      };
+      flush();
+    });
+
+    suite('assignee field', () => {
+      const dummyAccount = createAccountWithId();
+      const change: ParsedChangeInfo = {
+        ...createParsedChange(),
+        actions: {
+          assignee: {enabled: false},
+        },
+        assignee: dummyAccount,
+      };
+      let deleteStub: SinonStubbedMember<RestApiService['deleteAssignee']>;
+      let setStub: SinonStubbedMember<RestApiService['setAssignee']>;
+
+      setup(() => {
+        deleteStub = stubRestApi('deleteAssignee');
+        setStub = stubRestApi('setAssignee');
+        element.serverConfig = {
+          ...createServerInfo(),
+          change: {
+            ...createChangeConfig(),
+            enable_assignee: true,
+          },
+        };
+      });
+
+      test('changing change recomputes _assignee', () => {
+        assert.isFalse(!!element._assignee?.length);
+        const change = element.change;
+        change!.assignee = dummyAccount;
+        element._changeChanged(change);
+        assert.deepEqual(element?._assignee?.[0], dummyAccount);
+      });
+
+      test('modifying _assignee calls API', () => {
+        assert.isFalse(!!element._assignee?.length);
+        element.set('_assignee', [dummyAccount]);
+        assert.isTrue(setStub.calledOnce);
+        assert.deepEqual(element.change!.assignee, dummyAccount);
+        element.set('_assignee', [dummyAccount]);
+        assert.isTrue(setStub.calledOnce);
+        element.set('_assignee', []);
+        assert.isTrue(deleteStub.calledOnce);
+        assert.equal(element.change!.assignee, undefined);
+        element.set('_assignee', []);
+        assert.isTrue(deleteStub.calledOnce);
+      });
+
+      test('_computeAssigneeReadOnly', () => {
+        let mutable = false;
+        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+        mutable = true;
+        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+        change.actions!.assignee!.enabled = true;
+        assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
+        mutable = false;
+        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+      });
+    });
+
+    test('changing topic', () => {
+      const newTopic = 'the new topic' as TopicName;
+      const setChangeTopicStub = stubRestApi('setChangeTopic').returns(
+        Promise.resolve(newTopic)
+      );
+      element._handleTopicChanged(new CustomEvent('test', {detail: newTopic}));
+      const topicChangedSpy = sinon.spy();
+      element.addEventListener('topic-changed', topicChangedSpy);
+      assert.isTrue(
+        setChangeTopicStub.calledWith(42 as NumericChangeId, newTopic)
+      );
+      return setChangeTopicStub.lastCall.returnValue.then(() => {
+        assert.equal(element.change!.topic, newTopic);
+        assert.isTrue(topicChangedSpy.called);
+      });
+    });
+
+    test('topic removal', () => {
+      const newTopic = 'the new topic' as TopicName;
+      const setChangeTopicStub = stubRestApi('setChangeTopic').returns(
+        Promise.resolve(newTopic)
+      );
+      const chip = element.shadowRoot!.querySelector('gr-linked-chip');
+      const remove = chip!.$.remove;
+      const topicChangedSpy = sinon.spy();
+      element.addEventListener('topic-changed', topicChangedSpy);
+      tap(remove);
+      assert.isTrue(chip?.disabled);
+      assert.isTrue(setChangeTopicStub.calledWith(42 as NumericChangeId));
+      return setChangeTopicStub.lastCall.returnValue.then(() => {
+        assert.isFalse(chip?.disabled);
+        assert.equal(element.change!.topic, '' as TopicName);
+        assert.isTrue(topicChangedSpy.called);
+      });
+    });
+
+    test('changing hashtag', () => {
+      flush();
+      element._newHashtag = 'new hashtag' as Hashtag;
+      const newHashtag: Hashtag[] = ['new hashtag' as Hashtag];
+      const setChangeHashtagStub = stubRestApi('setChangeHashtag').returns(
+        Promise.resolve(newHashtag)
+      );
+      element._handleHashtagChanged();
+      assert.isTrue(
+        setChangeHashtagStub.calledWith(42 as NumericChangeId, {
+          add: ['new hashtag' as Hashtag],
+        })
+      );
+      return setChangeHashtagStub.lastCall.returnValue.then(() => {
+        assert.equal(element.change!.hashtags, newHashtag);
+      });
+    });
+  });
+
+  test('editTopic', () => {
+    element.account = createAccountDetailWithId();
+    element.change = {
+      ...createParsedChange(),
+      actions: {topic: {enabled: true}},
+    };
+    flush();
+
+    const label = element.shadowRoot!.querySelector(
+      '.topicEditableLabel'
+    ) as GrEditableLabel;
+    assert.ok(label);
+    const openStub = sinon.stub(label, 'open');
+    element.editTopic();
+    flush();
+
+    assert.isTrue(openStub.called);
+  });
+
+  suite('plugin endpoints', () => {
+    test('endpoint params', done => {
+      element.change = createParsedChange();
+      element.revision = createRevision();
+      interface MetadataGrEndpointDecorator extends GrEndpointDecorator {
+        plugin: PluginApi;
+        change: ParsedChangeInfo;
+        revision: RevisionInfo;
+      }
+      let hookEl: MetadataGrEndpointDecorator;
+      let plugin: PluginApi;
+      pluginApi.install(
+        p => {
+          plugin = p;
+          plugin
+            .hook('change-metadata-item')
+            .getLastAttached()
+            .then(el => (hookEl = el as MetadataGrEndpointDecorator));
+        },
+        '0.1',
+        'http://some/plugins/url.js'
+      );
+      getPluginLoader().loadPlugins([]);
+      flush(() => {
+        assert.strictEqual(hookEl!.plugin, plugin);
+        assert.strictEqual(hookEl!.change, element.change);
+        assert.strictEqual(hookEl!.revision, element.revision);
+        done();
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
index cdac00a..1d7db85 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
@@ -20,8 +20,6 @@
 import '../../shared/gr-label/gr-label';
 import '../../shared/gr-label-info/gr-label-info';
 import '../../shared/gr-limited-text/gr-limited-text';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-requirements_html';
 import {customElement, property, observe} from '@polymer/decorators';
@@ -34,8 +32,9 @@
   LabelNameToInfoMap,
   LabelInfo,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {appContext} from '../../../services/app-context';
+import {labelCompare} from '../../../utils/label-util';
 
 interface ChangeRequirement extends Requirement {
   satisfied: boolean;
@@ -49,15 +48,14 @@
 }
 
 interface Label {
+  labelName: string;
   labelInfo: LabelInfo;
   icon: string;
   style: string;
 }
 
 @customElement('gr-change-requirements')
-class GrChangeRequirements extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrChangeRequirements extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -86,6 +84,8 @@
   @property({type: Boolean})
   _showOptionalLabels = true;
 
+  private readonly reporting = appContext.reportingService;
+
   _computeShowWip(change: ChangeInfo) {
     return change.work_in_progress;
   }
@@ -126,22 +126,19 @@
       LabelNameToInfoMap
     >
   ) {
-    const labels = labelsRecord.base;
-    this._optionalLabels = [];
-    this._requiredLabels = [];
+    const labels = labelsRecord.base || {};
+    const allLabels: Label[] = [];
 
-    for (const label of Object.keys(labels || {}).sort()) {
-      if (!hasOwnProperty(labels, label)) {
-        continue;
-      }
-
-      const labelInfo = labels[label];
-      const icon = this._computeLabelIcon(labelInfo);
-      const style = this._computeLabelClass(labelInfo);
-      const path = labelInfo.optional ? '_optionalLabels' : '_requiredLabels';
-
-      this.push(path, {label, icon, style, labelInfo});
+    for (const label of Object.keys(labels).sort(labelCompare)) {
+      allLabels.push({
+        labelName: label,
+        icon: this._computeLabelIcon(labels[label]),
+        style: this._computeLabelClass(labels[label]),
+        labelInfo: labels[label],
+      });
     }
+    this._optionalLabels = allLabels.filter(label => label.labelInfo.optional);
+    this._requiredLabels = allLabels.filter(label => !label.labelInfo.optional);
   }
 
   /**
@@ -178,21 +175,29 @@
     return `${value > 0 ? '+' : ''}${value}`;
   }
 
-  _computeShowHideIcon(showOptionalLabels: boolean) {
-    return showOptionalLabels ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
-  }
-
   _computeSectionClass(show: boolean) {
     return show ? '' : 'hidden';
   }
 
   _handleShowHide() {
     this._showOptionalLabels = !this._showOptionalLabels;
+    this.reporting.reportInteraction('toggle show all button', {
+      sectionName: 'optional labels',
+      toState: this._showOptionalLabels ? 'Show all' : 'Show less',
+    });
   }
 
   _computeSubmitRequirementEndpoint(item: ChangeRequirement | ChangeWIP) {
     return `submit-requirement-item-${item.type}`;
   }
+
+  _computeShowAllLabelText(_showOptionalLabels: boolean) {
+    if (_showOptionalLabels) {
+      return 'Show less';
+    } else {
+      return 'Show all';
+    }
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
index ef71314..d824d94 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
@@ -23,7 +23,7 @@
       width: 100%;
     }
     .status {
-      color: #ffa62f;
+      color: var(--warning-foreground);
       display: inline-block;
       text-align: center;
       vertical-align: top;
@@ -81,13 +81,30 @@
       color: var(--deemphasized-text-color);
       float: right;
     }
+    .show-all-button {
+      float: right;
+    }
+    .show-all-button iron-icon {
+      color: inherit;
+      --iron-icon-height: 18px;
+      --iron-icon-width: 18px;
+    }
     .spacer {
       height: var(--spacing-m);
     }
     gr-endpoint-param {
       display: none;
     }
+    .metadata-title {
+      font-weight: var(--font-weight-bold);
+      color: var(--deemphasized-text-color);
+      padding-left: var(--metadata-horizontal-padding);
+    }
+    .title .metadata-title {
+      padding-left: 0;
+    }
   </style>
+  <h3 class="metadata-title">Submit requirements</h3>
   <template is="dom-repeat" items="[[_requirements]]">
     <gr-endpoint-decorator
       class="submit-requirement-endpoints"
@@ -124,7 +141,7 @@
         <gr-limited-text
           class="name"
           limit="25"
-          text="[[item.label]]"
+          text="[[item.labelName]]"
         ></gr-limited-text>
       </div>
       <div class="value">
@@ -132,7 +149,7 @@
           change="{{change}}"
           account="[[account]]"
           mutable="[[mutable]]"
-          label="[[item.label]]"
+          label="[[item.labelName]]"
           label-info="[[item.labelInfo]]"
         ></gr-label-info>
       </div>
@@ -142,18 +159,22 @@
   <section
     class$="spacer [[_computeShowOptional(_optionalLabels.*)]]"
   ></section>
-  <section
-    show-bottom-border$="[[_showOptionalLabels]]"
-    on-click="_handleShowHide"
-    class$="showHide [[_computeShowOptional(_optionalLabels.*)]]"
-  >
-    <div class="title">Other labels</div>
+  <section class$="showHide [[_computeShowOptional(_optionalLabels.*)]]">
+    <div class="title">
+      <h3 class="metadata-title">Other labels</h3>
+    </div>
     <div class="value">
-      <iron-icon
-        id="showHide"
-        icon="[[_computeShowHideIcon(_showOptionalLabels)]]"
-      >
-      </iron-icon>
+      <gr-button link="" class="show-all-button" on-click="_handleShowHide"
+        >[[_computeShowAllLabelText(_showOptionalLabels)]]
+        <iron-icon
+          icon="gr-icons:expand-more"
+          hidden$="[[_showOptionalLabels]]"
+        ></iron-icon
+        ><iron-icon
+          icon="gr-icons:expand-less"
+          hidden$="[[!_showOptionalLabels]]"
+        ></iron-icon>
+      </gr-button>
     </div>
   </section>
   <template is="dom-repeat" items="[[_optionalLabels]]">
@@ -170,7 +191,7 @@
         <gr-limited-text
           class="name"
           limit="25"
-          text="[[item.label]]"
+          text="[[item.labelName]]"
         ></gr-limited-text>
       </div>
       <div class="value">
@@ -178,7 +199,7 @@
           change="{{change}}"
           account="[[account]]"
           mutable="[[mutable]]"
-          label="[[item.label]]"
+          label="[[item.labelName]]"
           label-info="[[item.labelInfo]]"
         ></gr-label-info>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
index c2fc72d..90f9d29 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
@@ -75,7 +75,7 @@
     assert.equal(element._optionalLabels.length, 1);
     assert.equal(element._requiredLabels.length, 1);
 
-    assert.equal(element._optionalLabels[0].label, 'opt_test');
+    assert.equal(element._optionalLabels[0].labelName, 'opt_test');
     assert.equal(element._optionalLabels[0].icon, 'gr-icons:schedule');
     assert.equal(element._optionalLabels[0].style, '');
     assert.ok(element._optionalLabels[0].labelInfo);
@@ -88,7 +88,7 @@
     assert.ok(element.shadowRoot
         .querySelector('section.optional'));
     MockInteractions.tap(element.shadowRoot
-        .querySelector('.showHide'));
+        .querySelector('.show-all-button'));
     flush();
 
     assert.isFalse(element._showOptionalLabels);
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
new file mode 100644
index 0000000..89f7b89
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -0,0 +1,560 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the 'License');
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an 'AS IS' BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from 'lit-html';
+import {css, customElement, property} from 'lit-element';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {appContext} from '../../../services/app-context';
+import {
+  CheckResult,
+  CheckRun,
+  aPluginHasRegistered$,
+  someProvidersAreLoading$,
+  allRunsLatest$,
+  errorMessage$,
+  loginCallback$,
+} from '../../../services/checks/checks-model';
+import {Category, Link, RunStatus} from '../../../api/checks';
+import {fireShowPrimaryTab} from '../../../utils/event-util';
+import '../../shared/gr-avatar/gr-avatar';
+import {
+  getResultsOf,
+  hasCompletedWithoutResults,
+  hasResultsOf,
+  iconForCategory,
+  iconForStatus,
+  isRunning,
+  isRunningOrHasCompleted,
+} from '../../../services/checks/checks-util';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {
+  CommentThread,
+  isResolved,
+  isUnresolved,
+  getFirstComment,
+  isRobotThread,
+  hasHumanReply,
+} from '../../../utils/comment-util';
+import {pluralize} from '../../../utils/string-util';
+import {AccountInfo} from '../../../types/common';
+import {notUndefined} from '../../../types/types';
+import {uniqueDefinedAvatar} from '../../../utils/account-util';
+import {PrimaryTab} from '../../../constants/constants';
+import {ChecksTabState, CommentTabState} from '../../../types/events';
+
+export enum SummaryChipStyles {
+  INFO = 'info',
+  WARNING = 'warning',
+  CHECK = 'check',
+  UNDEFINED = '',
+}
+
+@customElement('gr-summary-chip')
+export class GrSummaryChip extends GrLitElement {
+  @property()
+  icon = '';
+
+  @property()
+  styleType = SummaryChipStyles.UNDEFINED;
+
+  @property()
+  category?: CommentTabState;
+
+  private readonly reporting = appContext.reportingService;
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        .summaryChip {
+          color: var(--chip-color);
+          cursor: pointer;
+          display: inline-block;
+          padding: var(--spacing-xxs) var(--spacing-m) var(--spacing-xxs)
+            var(--spacing-s);
+          margin-right: var(--spacing-s);
+          border-radius: 12px;
+          border: 1px solid gray;
+          vertical-align: top;
+        }
+        iron-icon {
+          width: var(--line-height-small);
+          height: var(--line-height-small);
+          vertical-align: top;
+        }
+        .summaryChip.warning {
+          border-color: var(--warning-foreground);
+          background: var(--warning-background);
+        }
+        .summaryChip.warning:hover {
+          background: var(--warning-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .summaryChip.warning iron-icon {
+          color: var(--warning-foreground);
+        }
+        .summaryChip.check {
+          border-color: var(--gray-foreground);
+          background: var(--gray-background);
+        }
+        .summaryChip.check:hover {
+          background: var(--gray-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .summaryChip.check iron-icon {
+          color: var(--gray-foreground);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    const chipClass = `summaryChip font-small ${this.styleType}`;
+    const grIcon = this.icon ? `gr-icons:${this.icon}` : '';
+    return html`<button class="${chipClass}" @click="${this.handleClick}">
+      ${this.icon && html`<iron-icon icon="${grIcon}"></iron-icon>`}
+      <slot></slot>
+    </button>`;
+  }
+
+  private handleClick(e: MouseEvent) {
+    e.stopPropagation();
+    e.preventDefault();
+    this.reporting.reportInteraction('comment chip click', {
+      category: this.category,
+    });
+    fireShowPrimaryTab(this, PrimaryTab.COMMENT_THREADS, true, {
+      commentTab: this.category,
+    });
+  }
+}
+
+@customElement('gr-checks-chip')
+export class GrChecksChip extends GrLitElement {
+  @property()
+  icon = '';
+
+  @property()
+  text = '';
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: inline-block;
+        }
+        .checksChip {
+          color: var(--chip-color);
+          cursor: pointer;
+          display: inline-block;
+          margin-right: var(--spacing-s);
+          padding: var(--spacing-xxs) var(--spacing-m) var(--spacing-xxs)
+            var(--spacing-s);
+          border-radius: 12px;
+          border: 1px solid gray;
+          vertical-align: top;
+        }
+        .checksChip .text {
+          display: inline-block;
+          max-width: 120px;
+          white-space: nowrap;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          vertical-align: top;
+        }
+        iron-icon {
+          width: var(--line-height-small);
+          height: var(--line-height-small);
+          vertical-align: top;
+        }
+        .checksChip.error {
+          color: var(--error-foreground);
+          border-color: var(--error-foreground);
+          background: var(--error-background);
+        }
+        .checksChip.error:hover {
+          background: var(--error-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .checksChip.error iron-icon {
+          color: var(--error-foreground);
+        }
+        .checksChip.warning {
+          border-color: var(--warning-foreground);
+          background: var(--warning-background);
+        }
+        .checksChip.warning:hover {
+          background: var(--warning-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .checksChip.warning iron-icon {
+          color: var(--warning-foreground);
+        }
+        .checksChip.info-outline {
+          border-color: var(--info-foreground);
+          background: var(--info-background);
+        }
+        .checksChip.info-outline:hover {
+          background: var(--info-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .checksChip.info-outline iron-icon {
+          color: var(--info-foreground);
+        }
+        .checksChip.check-circle-outline {
+          border-color: var(--success-foreground);
+          background: var(--success-background);
+        }
+        .checksChip.check-circle-outline:hover {
+          background: var(--success-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .checksChip.check-circle-outline iron-icon {
+          color: var(--success-foreground);
+        }
+        .checksChip.timelapse {
+        }
+        .checksChip.timelapse {
+          border-color: var(--gray-foreground);
+          background: var(--gray-background);
+        }
+        .checksChip.timelapse:hover {
+          background: var(--gray-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .checksChip.timelapse iron-icon {
+          color: var(--gray-foreground);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    if (!this.text) return;
+    const chipClass = `checksChip font-small ${this.icon}`;
+    const grIcon = `gr-icons:${this.icon}`;
+    return html`
+      <div class="${chipClass}" role="button">
+        <iron-icon icon="${grIcon}"></iron-icon>
+        <div class="text">${this.text}</div>
+        <slot></slot>
+      </div>
+    `;
+  }
+}
+
+/** What is the maximum number of expanded checks chips? */
+const DETAILS_QUOTA = 3;
+
+@customElement('gr-change-summary')
+export class GrChangeSummary extends GrLitElement {
+  @property({type: Object})
+  changeComments?: ChangeComments;
+
+  @property({type: Array})
+  commentThreads?: CommentThread[];
+
+  @property({type: Object})
+  selfAccount?: AccountInfo;
+
+  @property()
+  runs: CheckRun[] = [];
+
+  @property()
+  showChecksSummary = false;
+
+  @property()
+  someProvidersAreLoading = false;
+
+  @property()
+  errorMessage?: string;
+
+  @property()
+  loginCallback?: () => void;
+
+  /** Is reset when rendering beings and decreases while chips are rendered. */
+  private detailsQuota = DETAILS_QUOTA;
+
+  constructor() {
+    super();
+    this.subscribe('runs', allRunsLatest$);
+    this.subscribe('showChecksSummary', aPluginHasRegistered$);
+    this.subscribe('someProvidersAreLoading', someProvidersAreLoading$);
+    this.subscribe('errorMessage', errorMessage$);
+    this.subscribe('loginCallback', loginCallback$);
+  }
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          color: var(--deemphasized-text-color);
+          max-width: 650px;
+          margin-bottom: var(--spacing-m);
+        }
+        .zeroState {
+          color: var(--primary-text-color);
+        }
+        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;
+          margin-right: var(--spacing-s);
+        }
+        .login gr-button {
+          margin: -4px var(--spacing-s);
+        }
+        td.key {
+          padding-right: var(--spacing-l);
+          padding-bottom: var(--spacing-m);
+        }
+        td.value {
+          padding-right: var(--spacing-l);
+          padding-bottom: var(--spacing-m);
+        }
+        iron-icon.launch {
+          color: var(--gray-foreground);
+          width: var(--line-height-small);
+          height: var(--line-height-small);
+          vertical-align: top;
+        }
+        gr-avatar {
+          height: var(--line-height-small, 16px);
+          width: var(--line-height-small, 16px);
+          vertical-align: top;
+          margin-right: var(--spacing-xs);
+        }
+      `,
+    ];
+  }
+
+  renderChecksError() {
+    if (!this.errorMessage) return;
+    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>
+    `;
+  }
+
+  renderChecksLogin() {
+    if (this.errorMessage || !this.loginCallback) return;
+    return html`
+      <div class="login zeroState">
+        Not logged in
+        <gr-button @click="${this.loginCallback}" link>Sign in</gr-button>
+      </div>
+    `;
+  }
+
+  renderChecksZeroState() {
+    if (this.errorMessage || this.loginCallback) return;
+    if (this.runs.some(isRunningOrHasCompleted)) return;
+    const msg = this.someProvidersAreLoading ? 'Loading...' : 'No results';
+    return html`<div class="loading zeroState">${msg}</div>`;
+  }
+
+  renderChecksChipForCategory(category: Category) {
+    if (this.errorMessage || this.loginCallback) return;
+    const icon = iconForCategory(category);
+    const runs = this.runs.filter(run => hasResultsOf(run, category));
+    const count = (run: CheckRun) => getResultsOf(run, category);
+    return this.renderChecksChip(icon, runs, category, count);
+  }
+
+  renderChecksChipForStatus(
+    status: RunStatus,
+    filter: (run: CheckRun) => boolean
+  ) {
+    if (this.errorMessage || this.loginCallback) return;
+    const icon = iconForStatus(status);
+    const runs = this.runs.filter(filter);
+    return this.renderChecksChip(icon, runs, status, () => []);
+  }
+
+  renderChecksChip(
+    icon: string,
+    runs: CheckRun[],
+    statusOrCategory: RunStatus | Category,
+    resultFilter: (run: CheckRun) => CheckResult[]
+  ) {
+    if (runs.length === 0) {
+      return html``;
+    }
+    if (runs.length <= this.detailsQuota) {
+      this.detailsQuota -= runs.length;
+      return runs.map(run => {
+        const allLinks = resultFilter(run)
+          .reduce(
+            (links, result) => links.concat(result.links ?? []),
+            [] as Link[]
+          )
+          .filter(link => link.primary);
+        const links = allLinks.length === 1 ? allLinks : [];
+        const text = `${run.checkName}`;
+        return html`<gr-checks-chip
+          class="${icon}"
+          .icon="${icon}"
+          .text="${text}"
+          @click="${() => this.onChipClick({checkName: run.checkName})}"
+          >${links.map(
+            link => html`
+              <a href="${link.url}" target="_blank" @click="${this.onLinkClick}"
+                ><iron-icon class="launch" icon="gr-icons:launch"></iron-icon
+              ></a>
+            `
+          )}
+        </gr-checks-chip>`;
+      });
+    }
+    // runs.length > this.detailsQuota
+    this.detailsQuota = 0;
+    const sum = runs.reduce(
+      (sum, run) => sum + (resultFilter(run).length || 1),
+      0
+    );
+    if (sum === 0) return;
+    return html`<gr-checks-chip
+      class="${icon}"
+      .icon="${icon}"
+      .text="${sum}"
+      @click="${() => this.onChipClick({statusOrCategory})}"
+    ></gr-checks-chip>`;
+  }
+
+  private onChipClick(state: ChecksTabState) {
+    fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
+      checksTab: state,
+    });
+  }
+
+  private onLinkClick(e: MouseEvent) {
+    // Prevents onChipClick() from reacting to <a> link clicks.
+    e.stopPropagation();
+  }
+
+  render() {
+    this.detailsQuota = DETAILS_QUOTA;
+    const commentThreads =
+      this.commentThreads?.filter(t => !isRobotThread(t) || hasHumanReply(t)) ??
+      [];
+    const countResolvedComments = commentThreads.filter(isResolved).length;
+    const unresolvedThreads = commentThreads.filter(isUnresolved);
+    const countUnresolvedComments = unresolvedThreads.length;
+    const unresolvedAuthors = this.getAccounts(unresolvedThreads);
+    const draftCount = this.changeComments?.computeDraftCount() ?? 0;
+    return html`
+      <div>
+        <table>
+          <tr ?hidden=${!this.showChecksSummary}>
+            <td class="key">Checks</td>
+            <td class="value">
+              ${this.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)}
+            </td>
+          </tr>
+          <tr>
+            <td class="key">Comments</td>
+            <td class="value">
+              <span
+                class="zeroState"
+                ?hidden=${!!countResolvedComments ||
+                !!draftCount ||
+                !!countUnresolvedComments}
+              >
+                No comments</span
+              ><gr-summary-chip
+                styleType=${SummaryChipStyles.WARNING}
+                category=${CommentTabState.DRAFTS}
+                icon="edit"
+                ?hidden=${!draftCount}
+              >
+                ${pluralize(draftCount, 'draft')}</gr-summary-chip
+              ><gr-summary-chip
+                styleType=${SummaryChipStyles.WARNING}
+                category=${CommentTabState.UNRESOLVED}
+                ?hidden=${!countUnresolvedComments}
+              >
+                ${unresolvedAuthors.map(
+                  account =>
+                    html`<gr-avatar
+                      .account="${account}"
+                      image-size="32"
+                    ></gr-avatar>`
+                )}
+                ${countUnresolvedComments} unresolved</gr-summary-chip
+              ><gr-summary-chip
+                styleType=${SummaryChipStyles.CHECK}
+                category=${CommentTabState.SHOW_ALL}
+                icon="markChatRead"
+                ?hidden=${!countResolvedComments}
+                >${countResolvedComments} resolved</gr-summary-chip
+              >
+            </td>
+          </tr>
+          <tr hidden>
+            <td class="key">Findings</td>
+            <td class="value"></td>
+          </tr>
+        </table>
+      </div>
+    `;
+  }
+
+  getAccounts(commentThreads: CommentThread[]): AccountInfo[] {
+    const uniqueAuthors = commentThreads
+      .map(getFirstComment)
+      .map(comment => comment?.author ?? this.selfAccount)
+      .filter(notUndefined)
+      .filter(account => !!account?.avatars?.[0]?.url)
+      .filter(uniqueDefinedAvatar);
+    return uniqueAuthors.slice(0, 3);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-summary': GrChangeSummary;
+    'gr-checks-chip': GrChecksChip;
+    'gr-summary-chip': GrSummaryChip;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
similarity index 68%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
index ac59f4f..cd297c7 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
@@ -14,8 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
+import '../../../test/common-test-setup-karma';
+import {GrChangeSummary} from './gr-change-summary';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+suite('gr-change-summary test', () => {
+  test('is defined', () => {
+    const el = document.createElement('gr-change-summary');
+    assert.instanceOf(el, GrChangeSummary);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 8a0bf0f..180f2a2 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -25,12 +25,11 @@
 import '../../shared/gr-change-status/gr-change-status';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-editable-content/gr-editable-content';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import '../../shared/gr-linked-text/gr-linked-text';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-change-actions/gr-change-actions';
+import '../gr-change-summary/gr-change-summary';
 import '../gr-change-metadata/gr-change-metadata';
 import '../../shared/gr-icons/gr-icons';
 import '../gr-commit-info/gr-commit-info';
@@ -42,10 +41,8 @@
 import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
 import '../gr-reply-dialog/gr-reply-dialog';
 import '../gr-thread-list/gr-thread-list';
-import '../gr-upload-help-dialog/gr-upload-help-dialog';
+import '../../checks/gr-checks-tab';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-view_html';
 import {
@@ -53,13 +50,15 @@
   Shortcut,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
-import {getComputedStyleValue} from '../../../utils/dom-util';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {pluralize} from '../../../utils/string-util';
+import {windowLocationReload} from '../../../utils/dom-util';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
+import {DiffViewMode} from '../../../api/diff';
 import {PrimaryTab, SecondaryTab} from '../../../constants/constants';
+
 import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages';
 import {appContext} from '../../../services/app-context';
 import {ChangeStatus} from '../../../constants/constants';
@@ -69,19 +68,16 @@
   fetchChangeUpdates,
   hasEditBasedOnCurrentPatchSet,
   hasEditPatchsetLoaded,
-  patchNumEquals,
   PatchSet,
 } from '../../../utils/patch-set-util';
-import {changeStatuses, changeStatusString} from '../../../utils/change-util';
-import {EventType} from '../../plugins/gr-plugin-types';
-import {customElement, property, observe} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+import {changeStatuses} from '../../../utils/change-util';
 import {
   changeIsAbandoned,
   changeIsMerged,
   changeIsOpen,
 } from '../../../utils/change-util';
+import {EventType as PluginEventType} from '../../../api/plugin';
+import {customElement, property, observe} 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';
@@ -103,7 +99,6 @@
   ConfigInfo,
   PreferencesInfo,
   CommitInfo,
-  DiffPreferencesInfo,
   RevisionInfo,
   EditInfo,
   LabelNameToInfoMap,
@@ -111,7 +106,12 @@
   QuickLabelInfo,
   ApprovalInfo,
   ElementPropertyDeepChange,
+  ChangeId,
+  RelatedChangeAndCommitInfo,
+  RelatedChangesInfo,
+  BasePatchSetNum,
 } from '../../../types/common';
+import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrReplyDialog, FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
 import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
 import {GrDownloadDialog} from '../gr-download-dialog/gr-download-dialog';
@@ -120,7 +120,7 @@
   GrCommentApi,
   ChangeComments,
 } from '../../diff/gr-comment-api/gr-comment-api';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {
   CommentThread,
@@ -138,47 +138,48 @@
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
 import {
-  EditRevisionInfo,
-  ParsedChangeInfo,
-} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {
   GrFileList,
   DEFAULT_NUM_FILES_SHOWN,
 } from '../gr-file-list/gr-file-list';
-import {ChangeViewState, isPolymerSpliceChange} from '../../../types/types';
+import {
+  ChangeViewState,
+  EditRevisionInfo,
+  isPolymerSpliceChange,
+  ParsedChangeInfo,
+} from '../../../types/types';
 import {
   CustomKeyboardEvent,
   EditableContentSaveEvent,
   OpenFixPreviewEvent,
   ShowAlertEventDetail,
   SwitchTabEvent,
+  ThreadListModifiedEvent,
+  TabState,
+  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,
+  fireEvent,
+  firePageError,
+  fireDialogChange,
+  fireTitleChange,
+} from '../../../utils/event-util';
+import {GerritView} from '../../../services/router/router-model';
+import {takeUntil} from 'rxjs/operators';
+import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
+import {Subject} from 'rxjs';
+import {debounce, DelayedTask} from '../../../utils/async-util';
+import {Timing} from '../../../constants/reporting';
 
-const CHANGE_ID_ERROR = {
-  MISMATCH: 'mismatch',
-  MISSING: 'missing',
-};
-const CHANGE_ID_REGEX_PATTERN = /^(Change-Id:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
-
-const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
+const MIN_LINES_FOR_COMMIT_COLLAPSE = 17;
 
 const REVIEWERS_REGEX = /^(R|CC)=/gm;
 const MIN_CHECK_INTERVAL_SECS = 0;
 
-// These are the same as the breakpoint set in CSS. Make sure both are changed
-// together.
-const BREAKPOINT_RELATED_SMALL = '50em';
-const BREAKPOINT_RELATED_MED = '75em';
-
-// In the event that the related changes medium width calculation is too close
-// to zero, provide some height.
-const MINIMUM_RELATED_MAX_HEIGHT = 100;
-
-const SMALL_RELATED_HEIGHT = 400;
-
 const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
 
 const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
@@ -193,21 +194,11 @@
   NEW_MESSAGE: 'There are new messages on this change',
 };
 
-enum DiffViewMode {
-  SIDE_BY_SIDE = 'SIDE_BY_SIDE',
-  UNIFIED = 'UNIFIED_DIFF',
-}
-
-const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
-const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
-const SEND_REPLY_TIMING_LABEL = 'SendReply';
 // Making the tab names more unique in case a plugin adds one with same name
 const ROBOT_COMMENTS_LIMIT = 10;
 
 export interface GrChangeView {
   $: {
-    restAPI: RestApiService & Element;
-    jsAPI: GrJsApiInterface;
     commentAPI: GrCommentApi;
     applyFixDialog: GrApplyFixDialog;
     fileList: GrFileList & Element;
@@ -217,21 +208,15 @@
     includedInDialog: GrIncludedInDialog;
     downloadOverlay: GrOverlay;
     downloadDialog: GrDownloadDialog;
-    uploadHelpOverlay: GrOverlay;
     replyOverlay: GrOverlay;
     replyDialog: GrReplyDialog;
     mainContent: HTMLDivElement;
-    relatedChanges: GrRelatedChangesList;
     changeStar: GrChangeStar;
     actions: GrChangeActions;
     commitMessage: HTMLDivElement;
     commitAndRelated: HTMLDivElement;
     metadata: GrChangeMetadata;
-    relatedChangesToggle: HTMLDivElement;
     mainChangeInfo: HTMLDivElement;
-    commitCollapseToggleButton: GrButton;
-    commitCollapseToggle: HTMLDivElement;
-    relatedChangesToggleButton: GrButton;
     replyBtn: GrButton;
   };
 }
@@ -239,9 +224,7 @@
 export type ChangeViewPatchRange = Partial<PatchRange>;
 
 @customElement('gr-change-view')
-export class GrChangeView extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrChangeView extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -264,7 +247,11 @@
    * @event show-auth-required
    */
 
-  reporting = appContext.reportingService;
+  private readonly reporting = appContext.reportingService;
+
+  private readonly jsAPI = appContext.jsApiService;
+
+  private readonly changeService = appContext.changeService;
 
   /**
    * URL params passed from the router.
@@ -365,8 +352,7 @@
     type: Boolean,
     computed:
       '_computeHideEditCommitMessage(_loggedIn, ' +
-      '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
-      '_commitCollapsible)',
+      '_editingCommitMessage, _change, _editMode)',
   })
   _hideEditCommitMessage?: boolean;
 
@@ -388,13 +374,6 @@
   @property({type: Number})
   _lineHeight?: number;
 
-  @property({
-    type: String,
-    computed:
-      '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
-  })
-  _changeIdCommitMessageError?: string;
-
   @property({type: Object})
   _patchRange?: ChangeViewPatchRange;
 
@@ -443,9 +422,6 @@
   @property({type: Boolean})
   _replyDisabled = true;
 
-  @property({type: String, computed: '_changeStatusString(_change)'})
-  _changeStatus?: string;
-
   @property({
     type: String,
     computed: '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
@@ -463,9 +439,6 @@
   })
   _commitCollapsible?: boolean;
 
-  @property({type: Boolean})
-  _relatedChangesCollapsed = true;
-
   @property({type: Number})
   _updateCheckTimerHandle?: number | null;
 
@@ -475,9 +448,6 @@
   })
   _editMode?: boolean;
 
-  @property({type: Boolean, observer: '_updateToggleContainerClass'})
-  _showRelatedToggle = false;
-
   @property({
     type: Boolean,
     computed: '_isParentCurrent(_currentRevisionActions)',
@@ -520,6 +490,11 @@
   @property({type: Number})
   _currentRobotCommentsPatchSet?: PatchSetNum;
 
+  // TODO(milutin) - remove once new gr-dialog will do it out of the box
+  // This removes rest of page from a11y tree, when reply dialog is open
+  @property({type: Boolean})
+  _changeViewAriaHidden = false;
+
   /**
    * this is a two-element tuple to always
    * hold the current active tab for both primary and secondary tabs
@@ -535,6 +510,16 @@
 
   _throttledToggleChangeStar?: EventListener;
 
+  @property({type: Boolean})
+  _showChecksTab = false;
+
+  @property({type: String})
+  _tabState?: TabState;
+
+  restApiService = appContext.restApiService;
+
+  checksService = appContext.checksService;
+
   keyboardShortcuts() {
     return {
       [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
@@ -547,7 +532,6 @@
       [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
       [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
       [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
-      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
       [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
       [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
       [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
@@ -558,20 +542,23 @@
     };
   }
 
+  disconnected$ = new Subject();
+
+  private replyRefitTask?: DelayedTask;
+
+  private scrollTask?: DelayedTask;
+
   /** @override */
-  connectedCallback() {
-    super.connectedCallback();
-    this._throttledToggleChangeStar = this._throttleWrap(e =>
-      this._handleToggleChangeStar(e as CustomKeyboardEvent)
-    );
+  ready() {
+    super.ready();
+    aPluginHasRegistered$.pipe(takeUntil(this.disconnected$)).subscribe(b => {
+      this._showChecksTab = b;
+    });
   }
 
-  /** @override */
-  created() {
-    super.created();
-
+  constructor() {
+    super();
     this.addEventListener('topic-changed', () => this._handleTopicChanged());
-
     this.addEventListener(
       // When an overlay is opened in a mobile viewport, the overlay has a full
       // screen view. When it has a full screen view, we do not want the
@@ -590,12 +577,20 @@
       this._handleReloadCommentThreads()
     );
 
+    this.addEventListener(
+      'thread-list-modified',
+      (e: ThreadListModifiedEvent) => this._handleReloadDiffComments(e)
+    );
+
     this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
+    this._throttledToggleChangeStar = this._throttleWrap(e =>
+      this._handleToggleChangeStar(e as CustomKeyboardEvent)
+    );
     this._getServerConfig().then(config => {
       this._serverConfig = config;
       this._replyDisabled = false;
@@ -604,7 +599,7 @@
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
       if (loggedIn) {
-        this.$.restAPI.getAccount().then(acct => {
+        this.restApiService.getAccount().then(acct => {
           this._account = acct;
         });
       }
@@ -642,11 +637,11 @@
       this._handleCommitMessageCancel()
     );
     this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
-    this.addEventListener('close-fix-preview', () => this._onCloseFixPreview());
-    this.listen(window, 'scroll', '_handleScroll');
-    this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+    this.addEventListener('close-fix-preview', e => this._onCloseFixPreview(e));
+    window.addEventListener('scroll', this.handleScroll);
+    document.addEventListener('visibilitychange', this.handleVisibilityChange);
 
-    this.addEventListener('show-primary-tab', e =>
+    this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
       this._setActivePrimaryTab(e)
     );
     this.addEventListener('show-secondary-tab', e =>
@@ -662,26 +657,28 @@
   }
 
   /** @override */
-  detached() {
-    super.detached();
-    this.unlisten(window, 'scroll', '_handleScroll');
-    this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+  disconnectedCallback() {
+    this.disconnected$.next();
+    window.removeEventListener('scroll', this.handleScroll);
+    document.removeEventListener(
+      'visibilitychange',
+      this.handleVisibilityChange
+    );
+    this.replyRefitTask?.cancel();
+    this.scrollTask?.cancel();
 
     if (this._updateCheckTimerHandle) {
       this._cancelUpdateCheckTimer();
     }
+    super.disconnectedCallback();
   }
 
   get messagesList(): GrMessagesList | null {
-    return this.shadowRoot!.querySelector('gr-messages-list');
+    return this.shadowRoot!.querySelector<GrMessagesList>('gr-messages-list');
   }
 
   get threadList(): GrThreadList | null {
-    return this.shadowRoot!.querySelector('gr-thread-list');
-  }
-
-  _changeStatusString(change: ChangeInfo) {
-    return changeStatusString(change);
+    return this.shadowRoot!.querySelector<GrThreadList>('gr-thread-list');
   }
 
   _setDiffViewMode(opt_reset?: boolean) {
@@ -706,8 +703,8 @@
     this.$.applyFixDialog.open(e);
   }
 
-  _onCloseFixPreview() {
-    this._reload();
+  _onCloseFixPreview(e: CloseFixPreviewEvent) {
+    if (e.detail.fixApplied) this._reload();
   }
 
   _handleToggleDiffMode(e: CustomKeyboardEvent) {
@@ -733,17 +730,18 @@
    * @param paperTabs - the parent tabs container
    */
   _setActiveTab(
-    paperTabs: PaperTabsElement,
+    paperTabs: PaperTabsElement | null,
     activeDetails: {
       activeTabName?: string;
       activeTabIndex?: number;
       scrollIntoView?: boolean;
     }
   ) {
+    if (!paperTabs) return;
     const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
-    const tabs = paperTabs.querySelectorAll('paper-tab') as NodeListOf<
-      HTMLElement
-    >;
+    const tabs = paperTabs.querySelectorAll(
+      'paper-tab'
+    ) as NodeListOf<HTMLElement>;
     let activeIndex = -1;
     if (activeTabIndex !== undefined) {
       activeIndex = activeTabIndex;
@@ -762,11 +760,14 @@
     }
     const tabName = tabs[activeIndex].dataset['name'];
     if (scrollIntoView) {
-      paperTabs.scrollIntoView();
+      paperTabs.scrollIntoView({block: 'center'});
     }
     if (paperTabs.selected !== activeIndex) {
+      // paperTabs.selected is undefined during rendering
+      if (paperTabs.selected !== undefined) {
+        this.reporting.reportInteraction('show-tab', {tabName});
+      }
       paperTabs.selected = activeIndex;
-      this.reporting.reportInteraction('show-tab', {tabName});
     }
     return tabName;
   }
@@ -775,9 +776,9 @@
    * Changes active primary tab.
    */
   _setActivePrimaryTab(e: SwitchTabEvent) {
-    const primaryTabs = this.shadowRoot!.querySelector(
+    const primaryTabs = this.shadowRoot!.querySelector<PaperTabsElement>(
       '#primaryTabs'
-    ) as PaperTabsElement;
+    );
     const activeTabName = this._setActiveTab(primaryTabs, {
       activeTabName: e.detail.tab,
       activeTabIndex: e.detail.value,
@@ -802,15 +803,16 @@
         this._selectedTabPluginHeader = '';
       }
     }
+    this._tabState = e.detail.tabState;
   }
 
   /**
    * Changes active secondary tab.
    */
   _setActiveSecondaryTab(e: SwitchTabEvent) {
-    const secondaryTabs = this.shadowRoot!.querySelector(
+    const secondaryTabs = this.shadowRoot!.querySelector<PaperTabsElement>(
       '#secondaryTabs'
-    ) as PaperTabsElement;
+    );
     const activeTabName = this._setActiveTab(secondaryTabs, {
       activeTabName: e.detail.tab,
       activeTabIndex: e.detail.value,
@@ -821,22 +823,17 @@
     }
   }
 
-  _handleEditCommitMessage() {
-    this._editingCommitMessage = true;
-    this.$.commitMessageEditor.focusTextarea();
-  }
-
   _handleCommitMessageSave(e: EditableContentSaveEvent) {
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
     // Trim trailing whitespace from each line.
     const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
 
-    this.$.jsAPI.handleCommitMessage(this._change, message);
+    this.jsAPI.handleCommitMessage(this._change, message);
 
     this.$.commitMessageEditor.disabled = true;
-    this.$.restAPI
+    this.restApiService
       .putChangeCommitMessage(this._changeNum, message)
       .then(resp => {
         this.$.commitMessageEditor.disabled = false;
@@ -854,7 +851,7 @@
   }
 
   _reloadWindow() {
-    window.location.reload();
+    windowLocationReload();
   }
 
   _handleCommitMessageCancel() {
@@ -887,16 +884,13 @@
     loggedIn: boolean,
     editing: boolean,
     change: ChangeInfo,
-    editMode?: boolean,
-    collapsed?: boolean,
-    collapsible?: boolean
+    editMode?: boolean
   ) {
     if (
       !loggedIn ||
       editing ||
       (change && change.status === ChangeStatus.MERGED) ||
-      editMode ||
-      (collapsed && collapsible)
+      editMode
     ) {
       return true;
     }
@@ -923,8 +917,7 @@
     const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
     const commentCnt = commentCount[patch._number] || 0;
     if (commentCnt === 0) return `Patchset ${patch._number}`;
-    const findingsText = commentCnt === 1 ? 'finding' : 'findings';
-    return `Patchset ${patch._number} (${commentCnt} ${findingsText})`;
+    return `Patchset ${patch._number} (${pluralize(commentCnt, 'finding')})`;
   }
 
   _computeRobotCommentsPatchSetDropdownItems(
@@ -1013,14 +1006,9 @@
   ) {
     if (!changeComments) return undefined;
     const draftCount = changeComments.computeDraftCount();
-    const unresolvedString = GrCountStringFormatter.computeString(
-      unresolvedCount,
-      'unresolved'
-    );
-    const draftString = GrCountStringFormatter.computePluralString(
-      draftCount,
-      'draft'
-    );
+    const unresolvedString =
+      unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
+    const draftString = pluralize(draftCount, 'draft');
 
     return (
       unresolvedString +
@@ -1108,6 +1096,11 @@
     this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
   }
 
+  onReplyOverlayCanceled() {
+    fireDialogChange(this, {canceled: true});
+    this._changeViewAriaHidden = false;
+  }
+
   _handleOpenDiffPrefs() {
     this.$.fileList.openDiffPrefs();
   }
@@ -1137,14 +1130,6 @@
     this.$.downloadOverlay.close();
   }
 
-  _handleOpenUploadHelpDialog() {
-    this.$.uploadHelpOverlay.open();
-  }
-
-  _handleCloseUploadHelpDialog() {
-    this.$.uploadHelpOverlay.close();
-  }
-
   _handleMessageReply(e: CustomEvent<{message: {message: string}}>) {
     const msg: string = e.detail.message.message;
     const quoteStr =
@@ -1168,25 +1153,23 @@
     this.addEventListener(
       'change-details-loaded',
       () => {
-        this.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
+        this.reporting.timeEnd(Timing.SEND_REPLY);
       },
       {once: true}
     );
-    this.$.replyOverlay.close();
+    this.$.replyOverlay.cancel();
     this._reload();
   }
 
   _handleReplyCancel() {
-    this.$.replyOverlay.close();
+    this.$.replyOverlay.cancel();
   }
 
   _handleReplyAutogrow() {
     // If the textarea resizes, we need to re-fit the overlay.
-    this.debounce(
-      'reply-overlay-refit',
-      () => {
-        this.$.replyOverlay.refit();
-      },
+    this.replyRefitTask = debounce(
+      this.replyRefitTask,
+      () => this.$.replyOverlay.refit(),
       REPLY_REFIT_DEBOUNCE_INTERVAL_MS
     );
   }
@@ -1199,15 +1182,13 @@
     this._openReplyDialog(target);
   }
 
-  _handleScroll() {
-    this.debounce(
-      'scroll',
-      () => {
-        this.viewState.scrollTop = document.body.scrollTop;
-      },
+  readonly handleScroll = () => {
+    this.scrollTask = debounce(
+      this.scrollTask,
+      () => (this.viewState.scrollTop = document.body.scrollTop),
       150
     );
-  }
+  };
 
   _setShownFiles(e: CustomEvent<{length: number}>) {
     this._shownFileCount = e.detail.length;
@@ -1231,7 +1212,7 @@
     }
 
     if (value.changeNum && value.project) {
-      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+      this.restApiService.setInProjectLookup(value.changeNum, value.project);
     }
 
     const patchChanged =
@@ -1242,6 +1223,11 @@
         this._patchRange.basePatchNum !== value.basePatchNum);
     const changeChanged = this._changeNum !== value.changeNum;
 
+    let rightPatchNumChanged =
+      this._patchRange &&
+      value.patchNum !== undefined &&
+      this._patchRange.patchNum !== value.patchNum;
+
     const patchRange: ChangeViewPatchRange = {
       patchNum: value.patchNum,
       basePatchNum: value.basePatchNum || ParentPatchSetNum,
@@ -1255,8 +1241,9 @@
     if (!changeChanged && patchChanged) {
       if (!patchRange.patchNum) {
         patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
+        rightPatchNumChanged = true;
       }
-      this._reloadPatchNumDependentResources().then(() => {
+      this._reloadPatchNumDependentResources(rightPatchNumChanged).then(() => {
         this._sendShowChangeEvent();
       });
       return;
@@ -1264,8 +1251,6 @@
 
     this._initialLoadComplete = false;
     this._changeNum = value.changeNum;
-    this.$.relatedChanges.clear();
-
     this._reload(true).then(() => {
       this._performPostLoadTasks();
     });
@@ -1301,7 +1286,7 @@
   _sendShowChangeEvent() {
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
-    this.$.jsAPI.handleEvent(EventType.SHOW_CHANGE, {
+    this.jsAPI.handleEvent(PluginEventType.SHOW_CHANGE, {
       change: this._change,
       patchNum: this._patchRange.patchNum,
       info: {mergeable: this._mergeable},
@@ -1315,7 +1300,7 @@
 
     this._sendShowChangeEvent();
 
-    this.async(() => {
+    setTimeout(() => {
       if (this.viewState.scrollTop) {
         document.documentElement.scrollTop = document.body.scrollTop = this.viewState.scrollTop;
       } else {
@@ -1361,7 +1346,7 @@
   }
 
   _handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     const hash = MSG_PREFIX + e.detail.id;
@@ -1427,10 +1412,10 @@
       if (this.viewState.showReplyDialog) {
         this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
         // TODO(kaspern@): Find a better signal for when to call center.
-        this.async(() => {
+        setTimeout(() => {
           this.$.replyOverlay.center();
         }, 100);
-        this.async(() => {
+        setTimeout(() => {
           this.$.replyOverlay.center();
         }, 1000);
         this.set('viewState.showReplyDialog', false);
@@ -1478,13 +1463,7 @@
     this.set('_patchRange.basePatchNum', parent);
 
     const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, title);
   }
 
   /**
@@ -1521,71 +1500,15 @@
     return GerritNav.getUrlForChange(change);
   }
 
-  _computeShowCommitInfo(changeStatus: string, current_revision: RevisionInfo) {
-    return changeStatus === 'Merged' && current_revision;
-  }
-
-  _computeMergedCommitInfo(
-    current_revision: CommitId,
-    revisions: {[revisionId: string]: RevisionInfo}
+  _computeShowCommitInfo(
+    changeStatuses: string[],
+    current_revision: RevisionInfo
   ) {
-    const rev = revisions[current_revision];
-    if (!rev || !rev.commit) {
-      return {};
-    }
-    // CommitInfo.commit is optional. Set commit in all cases to avoid error
-    // in <gr-commit-info>. @see Issue 5337
-    if (!rev.commit.commit) {
-      rev.commit.commit = current_revision;
-    }
-    return rev.commit;
-  }
-
-  _computeChangeIdClass(displayChangeId: string) {
-    return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
-  }
-
-  _computeTitleAttributeWarning(displayChangeId: string) {
-    if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
-      return 'Change-Id mismatch';
-    } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
-      return 'No Change-Id in commit message';
-    }
-    return undefined;
-  }
-
-  _computeChangeIdCommitMessageError(
-    commitMessage?: string,
-    change?: ChangeInfo
-  ) {
-    if (change === undefined) {
-      return undefined;
-    }
-
-    if (!commitMessage) {
-      return CHANGE_ID_ERROR.MISSING;
-    }
-
-    // Find the last match in the commit message:
-    let changeId;
-    let changeIdArr;
-
-    while ((changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage))) {
-      changeId = changeIdArr[2];
-    }
-
-    if (changeId) {
-      // A change-id is detected in the commit message.
-
-      if (changeId === change.change_id) {
-        // The change-id found matches the real change-id.
-        return null;
-      }
-      // The change-id found does not match the change-id.
-      return CHANGE_ID_ERROR.MISMATCH;
-    }
-    // There is no change-id in the commit message.
-    return CHANGE_ID_ERROR.MISSING;
+    return (
+      changeStatuses.length === 1 &&
+      changeStatuses[0] === 'Merged' &&
+      current_revision
+    );
   }
 
   _computeReplyButtonLabel(
@@ -1618,12 +1541,7 @@
     }
     this._getLoggedIn().then(isLoggedIn => {
       if (!isLoggedIn) {
-        this.dispatchEvent(
-          new CustomEvent('show-auth-required', {
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireEvent(this, 'show-auth-required');
         return;
       }
 
@@ -1654,19 +1572,11 @@
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
-    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Base is already selected.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+      fireAlert(this, 'Base is already selected.');
       return;
     }
     GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
@@ -1676,19 +1586,11 @@
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
-    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Left is already base.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+      fireAlert(this, 'Left is already base.');
       return;
     }
     GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
@@ -1698,21 +1600,12 @@
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      const detail: ShowAlertEventDetail = {
-        message: 'Latest is already selected.',
-      };
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail,
-          composed: true,
-          bubbles: true,
-        })
-      );
+    if (this._patchRange.patchNum === latestPatchNum) {
+      fireAlert(this, 'Latest is already selected.');
       return;
     }
     GerritNav.navigateToChange(
@@ -1726,26 +1619,18 @@
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Right is already latest.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+    if (this._patchRange.patchNum === latestPatchNum) {
+      fireAlert(this, 'Right is already latest.');
       return;
     }
     GerritNav.navigateToChange(
       this._change,
       latestPatchNum,
-      this._patchRange.patchNum
+      this._patchRange.patchNum as BasePatchSetNum
     );
   }
 
@@ -1753,23 +1638,15 @@
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
     if (
-      patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
-      patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+      this._patchRange.patchNum === latestPatchNum &&
+      this._patchRange.basePatchNum === ParentPatchSetNum
     ) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Already diffing base against latest.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Already diffing base against latest.');
       return;
     }
     GerritNav.navigateToChange(this._change, latestPatchNum);
@@ -1877,12 +1754,13 @@
         changeRecord.path
       );
     }
-    this.$.jsAPI.handleEvent(EventType.LABEL_CHANGE, {
+    this.jsAPI.handleEvent(PluginEventType.LABEL_CHANGE, {
       change: this._change,
     });
   }
 
   _openReplyDialog(section?: FocusTarget) {
+    if (!this._change) return;
     this.$.replyOverlay.open().finally(() => {
       // the following code should be executed no matter open succeed or not
       this._resetReplyOverlayFocusStops();
@@ -1890,29 +1768,25 @@
       flush();
       this.$.replyOverlay.center();
     });
+    fireDialogChange(this, {opened: true});
+    this._changeViewAriaHidden = true;
   }
 
   _handleGetChangeDetailError(response?: Response | null) {
-    this.dispatchEvent(
-      new CustomEvent('page-error', {
-        detail: {response},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    firePageError(response);
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   _getServerConfig() {
-    return this.$.restAPI.getConfig();
+    return this.restApiService.getConfig();
   }
 
   _getProjectConfig() {
-    if (!this._change) throw new Error('missing required change property');
-    return this.$.restAPI
+    assertIsDefined(this._change, '_change');
+    return this.restApiService
       .getProjectConfig(this._change.project)
       .then(config => {
         this._projectConfig = config;
@@ -1920,7 +1794,7 @@
   }
 
   _getPreferences() {
-    return this.$.restAPI.getPreferences();
+    return this.restApiService.getPreferences();
   }
 
   _prepareCommitMsgForLinkify(msg: string) {
@@ -1940,15 +1814,7 @@
       this._patchRange?.patchNum === EditPatchSetNum &&
       changeIsOpen(change)
     ) {
-      /* eslint-disable max-len */
-      const message = 'Change edit not found. Please create a change edit.';
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message},
-          bubbles: true,
-          composed: true,
-        })
-      );
+      fireAlert(this, 'Change edit not found. Please create a change edit.');
       GerritNav.navigateToChange(change);
       return;
     }
@@ -1958,14 +1824,9 @@
       (changeIsMerged(change) || changeIsAbandoned(change)) &&
       this._editMode
     ) {
-      const message =
-        'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.';
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message},
-          bubbles: true,
-          composed: true,
-        })
+      fireAlert(
+        this,
+        'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.'
       );
       GerritNav.navigateToChange(change);
       return;
@@ -2008,8 +1869,9 @@
   _getChangeDetail() {
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
-    const detailCompletes = this.$.restAPI.getChangeDetail(this._changeNum, r =>
-      this._handleGetChangeDetailError(r)
+    const detailCompletes = this.restApiService.getChangeDetail(
+      this._changeNum,
+      r => this._handleGetChangeDetailError(r)
     );
     const editCompletes = this._getEdit();
     const prefCompletes = this._getPreferences();
@@ -2049,10 +1911,11 @@
         this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
 
         this._change = change;
+        this.changeService.updateChange(change);
         if (
           !this._patchRange ||
           !this._patchRange.patchNum ||
-          patchNumEquals(this._patchRange.patchNum, currentRevision._number)
+          this._patchRange.patchNum === currentRevision._number
         ) {
           // CommitInfo.commit is optional, and may need patching.
           if (currentRevision.commit && !currentRevision.commit.commit) {
@@ -2074,7 +1937,7 @@
             }
           );
         }
-        return false;
+        return true;
       }
     );
   }
@@ -2098,7 +1961,7 @@
   _getEdit() {
     if (!this._changeNum)
       return Promise.reject(new Error('missing required changeNum property'));
-    return this.$.restAPI.getChangeEdit(this._changeNum, true);
+    return this.restApiService.getChangeEdit(this._changeNum, true);
   }
 
   _getLatestCommitMessage() {
@@ -2107,7 +1970,7 @@
     const lastpatchNum = computeLatestPatchNum(this._allPatchSets);
     if (lastpatchNum === undefined)
       throw new Error('missing lastPatchNum property');
-    return this.$.restAPI
+    return this.restApiService
       .getChangeCommitInfo(this._changeNum, lastpatchNum)
       .then(commitInfo => {
         if (!commitInfo) return;
@@ -2118,21 +1981,15 @@
   }
 
   _getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
-    if (change.current_revision) {
-      return change.current_revision;
-    }
+    if (change.current_revision) return change.current_revision;
     // current_revision may not be present in the case where the latest rev is
     // a draft and the user doesn’t have permission to view that rev.
     let latestRev = null;
     let latestPatchNum = -1 as PatchSetNum;
-    for (const rev in change.revisions) {
-      if (!hasOwnProperty(change.revisions, rev)) {
-        continue;
-      }
-
-      if (change.revisions[rev]._number > latestPatchNum) {
+    for (const [rev, revInfo] of Object.entries(change.revisions ?? {})) {
+      if (revInfo._number > latestPatchNum) {
         latestRev = rev;
-        latestPatchNum = change.revisions[rev]._number;
+        latestPatchNum = revInfo._number;
       }
     }
     return latestRev;
@@ -2164,7 +2021,7 @@
   }
 
   _getChangeCommitInfo() {
-    return this.$.restAPI
+    return this.restApiService
       .getChangeCommitInfo(this._changeNum!, this._patchRange!.patchNum!)
       .then(commitInfo => {
         this._commitInfo = commitInfo;
@@ -2189,9 +2046,12 @@
     this._robotCommentThreads = undefined;
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
+
     return this.$.commentAPI
-      .loadAll(this._changeNum)
-      .then(comments => this._recomputeComments(comments));
+      .loadAll(this._changeNum, this._patchRange?.patchNum)
+      .then(comments => {
+        this._recomputeComments(comments);
+      });
   }
 
   /**
@@ -2246,9 +2106,8 @@
       return Promise.resolve([]);
     }
     this._loading = true;
-    this._relatedChangesCollapsed = true;
-    this.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
-    this.reporting.time(CHANGE_DATA_TIMING_LABEL);
+    this.reporting.time(Timing.CHANGE_RELOAD);
+    this.reporting.time(Timing.CHANGE_DATA);
 
     // Array to house all promises related to data requests.
     const allDataPromises: Promise<unknown>[] = [];
@@ -2257,30 +2116,27 @@
     // are loaded.
     const detailCompletes = this._getChangeDetail();
     allDataPromises.push(detailCompletes);
+    this.checksService.reloadAll();
 
     // Resolves when the loading flag is set to false, meaning that some
     // change content may start appearing.
     const loadingFlagSet = detailCompletes
       .then(() => {
         this._loading = false;
-        this.dispatchEvent(
-          new CustomEvent('change-details-loaded', {
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireEvent(this, 'change-details-loaded');
       })
       .then(() => {
-        this.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
+        this.reporting.timeEnd(Timing.CHANGE_RELOAD);
         if (isLocationChange) {
           this.reporting.changeDisplayed();
         }
       });
 
-    // Resolves when the project config has loaded.
-    const projectConfigLoaded = detailCompletes.then(() =>
-      this._getProjectConfig()
-    );
+    // Resolves when the project config has successfully loaded.
+    const projectConfigLoaded = detailCompletes.then(success => {
+      if (!success) return Promise.resolve();
+      return this._getProjectConfig();
+    });
     allDataPromises.push(projectConfigLoaded);
 
     // Resolves when change comments have loaded (comments, drafts and robot
@@ -2346,14 +2202,32 @@
 
     if (isLocationChange) {
       this._editingCommitMessage = false;
-      const relatedChangesLoaded = coreDataPromise.then(() =>
-        this.$.relatedChanges.reload()
-      );
+      const relatedChangesLoaded = coreDataPromise.then(() => {
+        let relatedChangesPromise:
+          | Promise<RelatedChangesInfo | undefined>
+          | undefined;
+        const patchNum = this._computeLatestPatchNum(this._allPatchSets);
+        if (this._change && patchNum) {
+          relatedChangesPromise = this.restApiService
+            .getRelatedChanges(this._change._number, patchNum)
+            .then(response => {
+              if (this._change && response) {
+                this.hasParent = this._calculateHasParent(
+                  this._change.change_id,
+                  response.changes
+                );
+              }
+              return response;
+            });
+        }
+        // TODO: use returned Promise
+        this.getRelatedChangesList()?.reload(relatedChangesPromise);
+      });
       allDataPromises.push(relatedChangesLoaded);
     }
 
     Promise.all(allDataPromises).then(() => {
-      this.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
+      this.reporting.timeEnd(Timing.CHANGE_DATA);
       if (isLocationChange) {
         this.reporting.changeFullyLoaded();
       }
@@ -2363,11 +2237,36 @@
   }
 
   /**
+   * Determines whether or not the given change has a parent change. If there
+   * is a relation chain, and the change id is not the last item of the
+   * relation chain, there is a parent.
+   */
+  _calculateHasParent(
+    currentChangeId: ChangeId,
+    relatedChanges: RelatedChangeAndCommitInfo[]
+  ) {
+    return (
+      relatedChanges.length > 0 &&
+      relatedChanges[relatedChanges.length - 1].change_id !== currentChangeId
+    );
+  }
+
+  /**
    * Kicks off requests for resources that rely on the patch range
    * (`this._patchRange`) being defined.
    */
-  _reloadPatchNumDependentResources() {
-    return Promise.all([this._getCommitInfo(), this.$.fileList.reload()]);
+  _reloadPatchNumDependentResources(rightPatchNumChanged?: boolean) {
+    assertIsDefined(this._changeNum, '_changeNum');
+    if (!this._patchRange?.patchNum) throw new Error('missing patchNum');
+    const promises = [this._getCommitInfo(), this.$.fileList.reload()];
+    if (rightPatchNumChanged)
+      promises.push(
+        this.$.commentAPI.reloadPortedComments(
+          this._changeNum,
+          this._patchRange?.patchNum
+        )
+      );
+    return Promise.all(promises);
   }
 
   _getMergeability() {
@@ -2397,11 +2296,13 @@
     }
 
     this._mergeable = null;
-    return this.$.restAPI.getMergeable(this._changeNum).then(mergableInfo => {
-      if (mergableInfo) {
-        this._mergeable = mergableInfo.mergeable;
-      }
-    });
+    return this.restApiService
+      .getMergeable(this._changeNum)
+      .then(mergableInfo => {
+        if (mergableInfo) {
+          this._mergeable = mergableInfo.mergeable;
+        }
+      });
   }
 
   _computeCanStartReview(change: ChangeInfo) {
@@ -2420,19 +2321,6 @@
     return `Change ${changeNum}`;
   }
 
-  _computeCommitMessageCollapsed(collapsed?: boolean, collapsible?: boolean) {
-    return collapsible && collapsed;
-  }
-
-  _computeRelatedChangesClass(collapsed: boolean) {
-    return collapsed ? 'collapsed' : '';
-  }
-
-  _computeCollapseText(collapsed: boolean) {
-    // Symbols are up and down triangles.
-    return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
-  }
-
   /**
    * Returns the text to be copied when
    * click the copy icon next to change subject
@@ -2445,20 +2333,6 @@
     );
   }
 
-  _toggleCommitCollapsed() {
-    this._commitCollapsed = !this._commitCollapsed;
-    if (this._commitCollapsed) {
-      window.scrollTo(0, 0);
-    }
-  }
-
-  _toggleRelatedChangesCollapsed() {
-    this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
-    if (this._relatedChangesCollapsed) {
-      window.scrollTo(0, 0);
-    }
-  }
-
   _computeCommitCollapsible(commitMessage?: string) {
     if (!commitMessage) {
       return false;
@@ -2466,111 +2340,6 @@
     return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
   }
 
-  _getOffsetHeight(element: HTMLElement) {
-    return element.offsetHeight;
-  }
-
-  _getScrollHeight(element: HTMLElement) {
-    return element.scrollHeight;
-  }
-
-  /**
-   * Get the line height of an element to the nearest integer.
-   */
-  _getLineHeight(element: Element) {
-    const lineHeightStr = getComputedStyle(element).lineHeight;
-    return Math.round(Number(lineHeightStr.slice(0, lineHeightStr.length - 2)));
-  }
-
-  /**
-   * New max height for the related changes section, shorter than the existing
-   * change info height.
-   */
-  _updateRelatedChangeMaxHeight() {
-    // Takes into account approximate height for the expand button and
-    // bottom margin.
-    const EXTRA_HEIGHT = 30;
-    let newHeight;
-
-    if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`).matches) {
-      // In a small (mobile) view, give the relation chain some space.
-      newHeight = SMALL_RELATED_HEIGHT;
-    } else if (
-      window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`).matches
-    ) {
-      // Since related changes are below the commit message, but still next to
-      // metadata, the height should be the height of the metadata minus the
-      // height of the commit message to reduce jank. However, if that doesn't
-      // result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT.
-      // Note: extraHeight is to take into account margin/padding.
-      const medRelatedHeight = Math.max(
-        this._getOffsetHeight(this.$.mainChangeInfo) -
-          this._getOffsetHeight(this.$.commitMessage) -
-          2 * EXTRA_HEIGHT,
-        MINIMUM_RELATED_MAX_HEIGHT
-      );
-      newHeight = medRelatedHeight;
-    } else {
-      if (this._commitCollapsible) {
-        // Make sure the content is lined up if both areas have buttons. If
-        // the commit message is not collapsed, instead use the change info
-        // height.
-        newHeight = this._getOffsetHeight(this.$.commitMessage);
-      } else {
-        newHeight =
-          this._getOffsetHeight(this.$.commitAndRelated) - EXTRA_HEIGHT;
-      }
-    }
-    const stylesToUpdate: {[key: string]: string} = {};
-
-    // Get the line height of related changes, and convert it to the nearest
-    // integer.
-    const lineHeight = this._getLineHeight(this.$.relatedChanges);
-
-    // Figure out a new height that is divisible by the rounded line height.
-    const remainder = newHeight % lineHeight;
-    newHeight = newHeight - remainder;
-
-    stylesToUpdate['--relation-chain-max-height'] = `${newHeight}px`;
-
-    // Update the max-height of the relation chain to this new height.
-    if (this._commitCollapsible) {
-      stylesToUpdate['--related-change-btn-top-padding'] = `${remainder}px`;
-    }
-
-    this.updateStyles(stylesToUpdate);
-  }
-
-  _computeShowRelatedToggle() {
-    // Make sure the max height has been applied, since there is now content
-    // to populate.
-    if (!getComputedStyleValue('--relation-chain-max-height', this)) {
-      this._updateRelatedChangeMaxHeight();
-    }
-    // Prevents showMore from showing when click on related change, since the
-    // line height would be positive, but related changes height is 0.
-    if (!this._getScrollHeight(this.$.relatedChanges)) {
-      return (this._showRelatedToggle = false);
-    }
-
-    if (
-      this._getScrollHeight(this.$.relatedChanges) >
-      this._getOffsetHeight(this.$.relatedChanges) +
-        this._getLineHeight(this.$.relatedChanges)
-    ) {
-      return (this._showRelatedToggle = true);
-    }
-    return (this._showRelatedToggle = false);
-  }
-
-  _updateToggleContainerClass(showRelatedToggle: boolean) {
-    if (showRelatedToggle) {
-      this.$.relatedChangesToggle.classList.add('showToggle');
-    } else {
-      this.$.relatedChangesToggle.classList.remove('showToggle');
-    }
-  }
-
   _startUpdateCheckTimer() {
     if (
       !this._serverConfig ||
@@ -2581,10 +2350,10 @@
       return;
     }
 
-    this._updateCheckTimerHandle = this.async(() => {
-      if (!this._change) throw new Error('missing required change property');
+    this._updateCheckTimerHandle = window.setTimeout(() => {
+      assertIsDefined(this._change, '_change');
       const change = this._change;
-      fetchChangeUpdates(change, this.$.restAPI).then(result => {
+      fetchChangeUpdates(change, this.restApiService).then(result => {
         let toastMessage = null;
         if (!result.isLatest) {
           toastMessage = ReloadToastMessage.NEWER_REVISION;
@@ -2596,6 +2365,9 @@
           toastMessage = ReloadToastMessage.RESTORED;
         } else if (result.newMessages) {
           toastMessage = ReloadToastMessage.NEW_MESSAGE;
+          if (result.newMessages.author?.name) {
+            toastMessage += ` from ${result.newMessages.author.name}`;
+          }
         }
 
         // We have to make sure that the update is still relevant for the user.
@@ -2610,11 +2382,12 @@
 
         this._cancelUpdateCheckTimer();
         this.dispatchEvent(
-          new CustomEvent('show-alert', {
+          new CustomEvent<ShowAlertEventDetail>('show-alert', {
             detail: {
               message: toastMessage,
               // Persist this alert.
               dismissOnNavigation: true,
+              showDismiss: true,
               action: 'Reload',
               callback: () => {
                 this._reload(
@@ -2633,21 +2406,21 @@
 
   _cancelUpdateCheckTimer() {
     if (this._updateCheckTimerHandle) {
-      this.cancelAsync(this._updateCheckTimerHandle);
+      window.clearTimeout(this._updateCheckTimerHandle);
     }
     this._updateCheckTimerHandle = null;
   }
 
-  _handleVisibilityChange() {
+  private readonly handleVisibilityChange = () => {
     if (document.hidden && this._updateCheckTimerHandle) {
       this._cancelUpdateCheckTimer();
     } else if (!this._updateCheckTimerHandle) {
       this._startUpdateCheckTimer();
     }
-  }
+  };
 
   _handleTopicChanged() {
-    this.$.relatedChanges.reload();
+    this.getRelatedChangesList()?.reload();
   }
 
   _computeHeaderClass(editMode?: boolean) {
@@ -2677,16 +2450,16 @@
     }
 
     const patchRange = patchRangeRecord.base || {};
-    return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+    return patchRange.patchNum === EditPatchSetNum;
   }
 
   _handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
     e.preventDefault();
-    const controls = this.$.fileListHeader.shadowRoot!.querySelector(
+    const controls = this.$.fileListHeader.shadowRoot!.querySelector<GrEditControls>(
       '#editControls'
-    ) as GrEditControls | null;
+    );
     if (!controls) throw new Error('Missing edit controls');
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     const path = e.detail.path;
@@ -2721,7 +2494,7 @@
     if (!this._selectedRevision) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
 
     let patchNum: PatchSetNum;
     if (patchNumStr === 'edit') {
@@ -2761,10 +2534,7 @@
       throw new Error('missing required _patchRange property');
     let patchNum;
     if (
-      !patchNumEquals(
-        this._patchRange.patchNum,
-        computeLatestPatchNum(this._allPatchSets)
-      )
+      !(this._patchRange.patchNum === computeLatestPatchNum(this._allPatchSets))
     ) {
       patchNum = this._patchRange.patchNum;
     }
@@ -2772,7 +2542,7 @@
   }
 
   _handleStopEditTap() {
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
@@ -2783,7 +2553,10 @@
   }
 
   _handleToggleStar(e: CustomEvent<{change: ChangeInfo; starred: boolean}>) {
-    this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+    this.restApiService.saveChangeStarred(
+      e.detail.change._number,
+      e.detail.starred
+    );
   }
 
   _getRevisionInfo(change: ChangeInfo | ParsedChangeInfo) {
@@ -2804,7 +2577,7 @@
   /**
    * Wrapper for using in the element template and computed properties
    */
-  _computeLatestPatchNum(allPatchSets: PatchSet[]) {
+  _computeLatestPatchNum(allPatchSets?: PatchSet[]) {
     return computeLatestPatchNum(allPatchSets);
   }
 
@@ -2837,6 +2610,12 @@
   _computeAllPatchSets(change: ChangeInfo) {
     return computeAllPatchSets(change);
   }
+
+  getRelatedChangesList() {
+    return this.shadowRoot!.querySelector<GrRelatedChangesList>(
+      '#relatedChanges'
+    );
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index efc86bc..6d8c1ec 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -65,7 +65,7 @@
       margin-left: var(--spacing-s);
     }
     #replyBtn {
-      margin-bottom: var(--spacing-l);
+      margin-bottom: var(--spacing-m);
     }
     gr-change-star {
       margin-left: var(--spacing-s);
@@ -91,11 +91,6 @@
       background-color: var(--view-background-color);
       box-shadow: var(--elevation-level-1);
     }
-    .changeId {
-      color: var(--deemphasized-text-color);
-      font-family: var(--font-family);
-      margin-top: var(--spacing-l);
-    }
     .changeMetadata {
       /* Limit meta section to half of the screen at max */
       max-width: 50%;
@@ -105,7 +100,7 @@
       font-size: var(--font-size-mono);
       line-height: var(--line-height-mono);
       margin-right: var(--spacing-l);
-      margin-bottom: var(--spacing-l);
+      margin-bottom: var(--spacing-m);
       /* Account for border and padding and rounding errors. */
       max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
     }
@@ -115,13 +110,7 @@
     #commitMessageEditor {
       /* Account for border and padding and rounding errors. */
       min-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
-    }
-    .editCommitMessage {
-      margin-top: var(--spacing-l);
-
-      --gr-button: {
-        padding: 5px 0px;
-      }
+      --collapsed-max-height: 300px;
     }
     .changeStatuses,
     .commitActions,
@@ -145,27 +134,21 @@
       overflow-x: hidden;
     }
     .relatedChanges {
-      flex: 1 1 auto;
+      flex: 0 1 auto;
       overflow: hidden;
       padding: var(--spacing-l) 0;
     }
     .mobile {
       display: none;
     }
-    .warning {
-      color: var(--error-text-color);
-    }
     hr {
       border: 0;
       border-top: 1px solid var(--border-color);
       height: 0;
       margin-bottom: var(--spacing-l);
     }
-    #relatedChanges.collapsed {
-      margin-bottom: var(--spacing-l);
-      max-height: var(--relation-chain-max-height, 2em);
-      overflow: hidden;
-      position: relative; /* for arrowToCurrentChange to have position:absolute and be hidden */
+    .emptySpace {
+      flex-grow: 1;
     }
     .commitContainer {
       display: flex;
@@ -174,23 +157,6 @@
       margin: var(--spacing-l) 0;
       padding: 0 var(--spacing-l);
     }
-    .collapseToggleContainer {
-      display: flex;
-      margin-bottom: 8px;
-    }
-    #relatedChangesToggle {
-      display: none;
-    }
-    #relatedChangesToggle.showToggle {
-      display: flex;
-    }
-    .collapseToggleContainer gr-button {
-      display: block;
-    }
-    #relatedChangesToggle {
-      margin-left: var(--spacing-l);
-      padding-top: var(--related-change-btn-top-padding, 0);
-    }
     .showOnEdit {
       display: none;
     }
@@ -232,8 +198,10 @@
       padding-top: var(--spacing-l);
       width: 100%;
     }
-    /* NOTE: If you update this breakpoint, also update the
-      BREAKPOINT_RELATED_MED in the JS */
+    gr-change-summary {
+      /* temporary for old checks status */
+      margin-bottom: var(--spacing-m);
+    }
     @media screen and (max-width: 75em) {
       .relatedChanges {
         padding: 0;
@@ -255,8 +223,6 @@
         padding-right: 0;
       }
     }
-    /* NOTE: If you update this breakpoint, also update the
-      BREAKPOINT_RELATED_SMALL in the JS */
     @media screen and (max-width: 50em) {
       .mobile {
         display: block;
@@ -346,6 +312,7 @@
     class="container"
     on-show-checks-table="_setActivePrimaryTab"
     hidden$="{{_loading}}"
+    aria-hidden="[[_changeViewAriaHidden]]"
   >
     <section class="changeInfoSection">
       <div class$="[[_computeHeaderClass(_editMode)]]">
@@ -361,19 +328,6 @@
               ></gr-change-status>
             </template>
           </div>
-          <div class="statusText">
-            <template
-              is="dom-if"
-              if="[[_computeShowCommitInfo(_changeStatus, _change.current_revision)]]"
-            >
-              <span class="text"> as </span>
-              <gr-commit-info
-                change="[[_change]]"
-                commit-info="[[_computeMergedCommitInfo(_change.current_revision, _change.revisions)]]"
-                server-config="[[_serverConfig]]"
-              ></gr-commit-info>
-            </template>
-          </div>
           <gr-change-star
             id="changeStar"
             change="{{_change}}"
@@ -405,6 +359,7 @@
             has-parent="[[hasParent]]"
             actions="[[_change.actions]]"
             revision-actions="{{_currentRevisionActions}}"
+            account="[[_account]]"
             change-num="[[_changeNum]]"
             change-status="[[_change.status]]"
             commit-num="[[_commitInfo.commit]]"
@@ -417,6 +372,7 @@
             on-edit-tap="_handleEditTap"
             on-stop-edit-tap="_handleStopEditTap"
             on-download-tap="_handleOpenDownloadDialog"
+            comment-threads="[[_commentThreads]]"
           ></gr-change-actions>
         </div>
         <!-- end commit actions -->
@@ -457,11 +413,12 @@
               <div id="commitMessage" class="commitMessage">
                 <gr-editable-content
                   id="commitMessageEditor"
-                  editing="[[_editingCommitMessage]]"
+                  editing="{{_editingCommitMessage}}"
                   content="{{_latestCommitMessage}}"
                   storage-key="[[_computeCommitMessageKey(_change._number, _change.current_revision)]]"
+                  hide-edit-commit-message="[[_hideEditCommitMessage]]"
+                  commit-collapsible="[[_commitCollapsible]]"
                   remove-zero-width-space=""
-                  collapsed$="[[_computeCommitMessageCollapsed(_commitCollapsed, _commitCollapsible)]]"
                 >
                   <gr-linked-text
                     pre=""
@@ -470,42 +427,13 @@
                     remove-zero-width-space=""
                   ></gr-linked-text>
                 </gr-editable-content>
-                <gr-button
-                  link=""
-                  class="editCommitMessage"
-                  title="Edit commit message"
-                  on-click="_handleEditCommitMessage"
-                  hidden$="[[_hideEditCommitMessage]]"
-                  >Edit</gr-button
-                >
-                <div
-                  class="changeId"
-                  hidden$="[[!_changeIdCommitMessageError]]"
-                >
-                  <hr />
-                  Change-Id:
-                  <span
-                    class$="[[_computeChangeIdClass(_changeIdCommitMessageError)]]"
-                    title$="[[_computeTitleAttributeWarning(_changeIdCommitMessageError)]]"
-                  >
-                    [[_change.change_id]]
-                  </span>
-                </div>
               </div>
-              <div
-                id="commitCollapseToggle"
-                class="collapseToggleContainer"
-                hidden$="[[!_commitCollapsible]]"
+              <gr-change-summary
+                change-comments="[[_changeComments]]"
+                comment-threads="[[_commentThreads]]"
+                self-account="[[_account]]"
               >
-                <gr-button
-                  link=""
-                  id="commitCollapseToggleButton"
-                  class="collapseToggleButton"
-                  on-click="_toggleCommitCollapsed"
-                >
-                  [[_computeCollapseText(_commitCollapsed)]]
-                </gr-button>
-              </div>
+              </gr-change-summary>
               <gr-endpoint-decorator name="commit-container">
                 <gr-endpoint-param name="change" value="[[_change]]">
                 </gr-endpoint-param>
@@ -518,27 +446,13 @@
             </div>
             <div class="relatedChanges">
               <gr-related-changes-list
-                id="relatedChanges"
-                class$="[[_computeRelatedChangesClass(_relatedChangesCollapsed)]]"
                 change="[[_change]]"
+                id="relatedChanges"
                 mergeable="[[_mergeable]]"
-                has-parent="{{hasParent}}"
-                on-update="_updateRelatedChangeMaxHeight"
                 patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
-                on-new-section-loaded="_computeShowRelatedToggle"
-              >
-              </gr-related-changes-list>
-              <div id="relatedChangesToggle" class="collapseToggleContainer">
-                <gr-button
-                  link=""
-                  id="relatedChangesToggleButton"
-                  class="collapseToggleButton"
-                  on-click="_toggleRelatedChangesCollapsed"
-                >
-                  [[_computeCollapseText(_relatedChangesCollapsed)]]
-                </gr-button>
-              </div>
+              ></gr-related-changes-list>
             </div>
+            <div class="emptySpace"></div>
           </div>
         </div>
       </div>
@@ -558,6 +472,11 @@
           <span>Comments</span></gr-tooltip-content
         >
       </paper-tab>
+      <template is="dom-if" if="[[_showChecksTab]]">
+        <paper-tab data-name$="[[_constants.PrimaryTab.CHECKS]]"
+          >Checks</paper-tab
+        >
+      </template>
       <template
         is="dom-repeat"
         items="[[_dynamicTabHeaderEndpoints]]"
@@ -603,7 +522,6 @@
           diff-prefs-disabled="[[_diffPrefsDisabled]]"
           on-open-diff-prefs="_handleOpenDiffPrefs"
           on-open-download-dialog="_handleOpenDownloadDialog"
-          on-open-upload-help-dialog="_handleOpenUploadHelpDialog"
           on-open-included-in-dialog="_handleOpenIncludedInDialog"
           on-expand-diffs="_expandAllDiffs"
           on-collapse-diffs="_collapseAllDiffs"
@@ -617,9 +535,7 @@
           change-num="[[_changeNum]]"
           patch-range="{{_patchRange}}"
           change-comments="[[_changeComments]]"
-          drafts="[[_diffDrafts]]"
           revisions="[[_change.revisions]]"
-          project-config="[[_projectConfig]]"
           selected-index="{{viewState.selectedFileIndex}}"
           diff-view-mode="[[viewState.diffMode]]"
           edit-mode="[[_editMode]]"
@@ -641,13 +557,23 @@
           change="[[_change]]"
           change-num="[[_changeNum]]"
           logged-in="[[_loggedIn]]"
+          comment-tab-state="[[_tabState.commentTab]]"
           only-show-robot-comments-with-human-reply=""
-          on-thread-list-modified="_handleReloadDiffComments"
           unresolved-only
+          show-comment-context
         ></gr-thread-list>
       </template>
       <template
         is="dom-if"
+        if="[[_isTabActive(_constants.PrimaryTab.CHECKS, _activeTabs)]]"
+      >
+        <gr-checks-tab
+          id="checksTab"
+          tab-state="[[_tabState.checksTab]]"
+        ></gr-checks-tab>
+      </template>
+      <template
+        is="dom-if"
         if="[[_isTabActive(_constants.PrimaryTab.FINDINGS, _activeTabs)]]"
       >
         <gr-dropdown-list
@@ -664,7 +590,6 @@
           logged-in="[[_loggedIn]]"
           hide-toggle-buttons
           empty-thread-msg="[[_messages.NO_ROBOT_COMMENTS_THREADS_MSG]]"
-          on-thread-list-modified="_handleReloadDiffComments"
         >
         </gr-thread-list>
         <template is="dom-if" if="[[_showRobotCommentsButton]]">
@@ -742,13 +667,6 @@
       on-close="_handleDownloadDialogClose"
     ></gr-download-dialog>
   </gr-overlay>
-  <gr-overlay id="uploadHelpOverlay" with-backdrop="">
-    <gr-upload-help-dialog
-      revision="[[_currentRevision]]"
-      target-branch="[[_change.branch]]"
-      on-close="_handleCloseUploadHelpDialog"
-    ></gr-upload-help-dialog>
-  </gr-overlay>
   <gr-overlay id="includedInOverlay" with-backdrop="">
     <gr-included-in-dialog
       id="includedInDialog"
@@ -763,6 +681,7 @@
     no-cancel-on-esc-key=""
     scroll-action="lock"
     with-backdrop=""
+    on-iron-overlay-canceled="onReplyOverlayCanceled"
   >
     <gr-reply-dialog
       id="replyDialog"
@@ -781,7 +700,5 @@
     >
     </gr-reply-dialog>
   </gr-overlay>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   <gr-comment-api id="commentAPI"></gr-comment-api>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 8d06a03..d8aebcb 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
@@ -29,21 +29,21 @@
 } from '../../../constants/constants';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {getComputedStyleValue} from '../../../utils/dom-util';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
-import {EventType, PluginApi} from '../../plugins/gr-plugin-types';
+import {EventType, PluginApi} from '../../../api/plugin';
 
 import 'lodash/lodash';
-import {TestKeyboardShortcutBinder} from '../../../test/test-utils';
-import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util';
+import {
+  stubRestApi,
+  TestKeyboardShortcutBinder,
+} from '../../../test/test-utils';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
   createAppElementChangeViewParams,
   createApproval,
   createChange,
-  createChangeConfig,
   createChangeMessages,
   createCommit,
   createMergeable,
@@ -54,14 +54,16 @@
   createUserConfig,
   TEST_NUMERIC_CHANGE_ID,
   TEST_PROJECT_NAME,
-  getCurrentRevision,
   createEditRevision,
   createAccountWithIdNameAndEmail,
+  createChangeViewChange,
+  createRelatedChangeAndCommitInfo,
 } from '../../../test/test-data-generators';
 import {ChangeViewPatchRange, GrChangeView} from './gr-change-view';
 import {
   AccountId,
   ApprovalInfo,
+  BasePatchSetNum,
   ChangeId,
   ChangeInfo,
   CommitId,
@@ -72,10 +74,11 @@
   GitRef,
   NumericChangeId,
   ParentPatchSetNum,
-  ParsedJSON,
   PatchRange,
   PatchSetNum,
+  RelatedChangeAndCommitInfo,
   RevisionInfo,
+  RevisionPatchSetNum,
   RobotId,
   Timestamp,
   UrlEncodedCommentId,
@@ -86,12 +89,8 @@
 } from '@polymer/iron-test-helpers/mock-interactions';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {AppElementChangeViewParams} from '../../gr-app-types';
-import {
-  SinonFakeTimers,
-  SinonSpy,
-  SinonStubbedMember,
-} from 'sinon/pkg/sinon-esm';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {SinonFakeTimers, SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {CustomKeyboardEvent} from '../../../types/events';
 import {
   CommentThread,
@@ -99,22 +98,21 @@
   UIDraft,
   UIRobot,
 } from '../../../utils/comment-util';
-import 'lodash/lodash';
-import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {GerritView} from '../../../services/router/router-model';
+import {ParsedChangeInfo} from '../../../types/types';
+import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
+import {appContext} from '../../../services/app-context';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 const fixture = fixtureFromElement('gr-change-view');
 
-type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
-  Parameters<F>,
-  ReturnType<F>
->;
-
 suite('gr-change-view tests', () => {
   let element: GrChangeView;
 
-  let navigateToChangeStub: SinonStubbedMember<typeof GerritNav.navigateToChange>;
+  let navigateToChangeStub: SinonStubbedMember<
+    typeof GerritNav.navigateToChange
+  >;
 
   suiteSetup(() => {
     const kb = TestKeyboardShortcutBinder.push();
@@ -144,7 +142,7 @@
     {
       comments: [
         {
-          __path: '/COMMIT_MSG',
+          path: '/COMMIT_MSG',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -159,7 +157,7 @@
           unresolved: true,
         },
         {
-          __path: '/COMMIT_MSG',
+          path: '/COMMIT_MSG',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -186,15 +184,16 @@
           patch_set: 2 as PatchSetNum,
         },
       ],
-      patchNum: 4 as PatchSetNum,
+      patchNum: 4 as RevisionPatchSetNum,
       path: '/COMMIT_MSG',
       line: 5,
       rootId: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
     {
       comments: [
         {
-          __path: '/COMMIT_MSG',
+          path: '/COMMIT_MSG',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -209,7 +208,7 @@
           unresolved: true,
         },
         {
-          __path: 'test.txt',
+          path: 'test.txt',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -231,7 +230,7 @@
     {
       comments: [
         {
-          __path: '/COMMIT_MSG',
+          path: '/COMMIT_MSG',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -245,15 +244,16 @@
           unresolved: true,
         },
       ],
-      patchNum: 2 as PatchSetNum,
+      patchNum: 2 as RevisionPatchSetNum,
       path: '/COMMIT_MSG',
       line: 4,
       rootId: '8caddf38_44770ec1' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
     {
       comments: [
         {
-          __path: '/COMMIT_MSG',
+          path: '/COMMIT_MSG',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -267,10 +267,11 @@
           unresolved: true,
         },
       ],
-      patchNum: 2 as PatchSetNum,
+      patchNum: 2 as RevisionPatchSetNum,
       path: '/COMMIT_MSG',
       line: 4,
       rootId: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
     {
       comments: [
@@ -287,15 +288,16 @@
           patch_set: 2 as PatchSetNum,
         },
       ],
-      patchNum: 4 as PatchSetNum,
+      patchNum: 4 as RevisionPatchSetNum,
       path: '/COMMIT_MSG',
       line: 6,
       rootId: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
     {
       comments: [
         {
-          __path: '/COMMIT_MSG',
+          path: '/COMMIT_MSG',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -310,15 +312,16 @@
           robot_id: 'rc1' as RobotId,
         },
       ],
-      patchNum: 4 as PatchSetNum,
+      patchNum: 4 as RevisionPatchSetNum,
       path: '/COMMIT_MSG',
       line: 5,
       rootId: 'rc1' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
     {
       comments: [
         {
-          __path: '/COMMIT_MSG',
+          path: '/COMMIT_MSG',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -333,7 +336,7 @@
           robot_id: 'rc2' as RobotId,
         },
         {
-          __path: '/COMMIT_MSG',
+          path: '/COMMIT_MSG',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -347,10 +350,11 @@
           unresolved: true,
         },
       ],
-      patchNum: 4 as PatchSetNum,
+      patchNum: 4 as RevisionPatchSetNum,
       path: '/COMMIT_MSG',
       line: 5,
       rootId: 'rc2' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
   ];
 
@@ -359,29 +363,19 @@
     _testOnly_resetEndpoints();
     navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
 
-    function getCommentsStub() {
-      return Promise.resolve({});
-    }
-    stub('gr-rest-api-interface', {
-      getConfig() {
-        return Promise.resolve({
-          ...createServerInfo(),
-          user: {
-            ...createUserConfig(),
-            anonymous_coward_name: 'test coward name',
-          },
-        });
-      },
-      getAccount() {
-        return Promise.resolve(undefined);
-      },
-      getDiffComments: (getCommentsStub as unknown) as RestApiService['getDiffComments'],
-      getDiffRobotComments: (getCommentsStub as unknown) as RestApiService['getDiffRobotComments'],
-      getDiffDrafts: (getCommentsStub as unknown) as RestApiService['getDiffDrafts'],
-      _fetchSharedCacheURL() {
-        return Promise.resolve({} as ParsedJSON);
-      },
-    });
+    stubRestApi('getConfig').returns(
+      Promise.resolve({
+        ...createServerInfo(),
+        user: {
+          ...createUserConfig(),
+          anonymous_coward_name: 'test coward name',
+        },
+      })
+    );
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    stubRestApi('getDiffComments').returns(Promise.resolve({}));
+    stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+    stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
     element = fixture.instantiate();
     element._changeNum = 1 as NumericChangeId;
     sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
@@ -398,7 +392,7 @@
         );
       },
       '0.1',
-      'http://some/plugins/url.html'
+      'http://some/plugins/url.js'
     );
   });
 
@@ -408,16 +402,13 @@
     });
   });
 
-  const getCustomCssValue = (cssParam: string) =>
-    getComputedStyleValue(cssParam, element);
-
   test('_handleMessageAnchorTap', () => {
     element._changeNum = 1 as NumericChangeId;
     element._patchRange = {
       basePatchNum: ParentPatchSetNum,
-      patchNum: 1 as PatchSetNum,
+      patchNum: 1 as RevisionPatchSetNum,
     };
-    element._change = createChange();
+    element._change = createChangeViewChange();
     const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange');
     const replaceStateStub = sinon.stub(history, 'replaceState');
     element._handleMessageAnchorTap(
@@ -430,12 +421,12 @@
 
   test('_handleDiffAgainstBase', () => {
     element._change = {
-      ...createChange(),
+      ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
     element._patchRange = {
-      patchNum: 3 as PatchSetNum,
-      basePatchNum: 1 as PatchSetNum,
+      patchNum: 3 as RevisionPatchSetNum,
+      basePatchNum: 1 as BasePatchSetNum,
     };
     sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
     element._handleDiffAgainstBase(new CustomEvent('') as CustomKeyboardEvent);
@@ -447,12 +438,12 @@
 
   test('_handleDiffAgainstLatest', () => {
     element._change = {
-      ...createChange(),
+      ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
     element._patchRange = {
-      basePatchNum: 1 as PatchSetNum,
-      patchNum: 3 as PatchSetNum,
+      basePatchNum: 1 as BasePatchSetNum,
+      patchNum: 3 as RevisionPatchSetNum,
     };
     sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
     element._handleDiffAgainstLatest(
@@ -467,12 +458,12 @@
 
   test('_handleDiffBaseAgainstLeft', () => {
     element._change = {
-      ...createChange(),
+      ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
     element._patchRange = {
-      patchNum: 3 as PatchSetNum,
-      basePatchNum: 1 as PatchSetNum,
+      patchNum: 3 as RevisionPatchSetNum,
+      basePatchNum: 1 as BasePatchSetNum,
     };
     sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
     element._handleDiffBaseAgainstLeft(
@@ -486,12 +477,12 @@
 
   test('_handleDiffRightAgainstLatest', () => {
     element._change = {
-      ...createChange(),
+      ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
     element._patchRange = {
-      basePatchNum: 1 as PatchSetNum,
-      patchNum: 3 as PatchSetNum,
+      basePatchNum: 1 as BasePatchSetNum,
+      patchNum: 3 as RevisionPatchSetNum,
     };
     sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
     element._handleDiffRightAgainstLatest(
@@ -505,12 +496,12 @@
 
   test('_handleDiffBaseAgainstLatest', () => {
     element._change = {
-      ...createChange(),
+      ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
     element._patchRange = {
-      basePatchNum: 1 as PatchSetNum,
-      patchNum: 3 as PatchSetNum,
+      basePatchNum: 1 as BasePatchSetNum,
+      patchNum: 3 as RevisionPatchSetNum,
     };
     sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
     element._handleDiffBaseAgainstLatest(
@@ -693,14 +684,14 @@
     test('A toggles overlay when logged in', done => {
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
       element._change = {
-        ...createChange(),
+        ...createChangeViewChange(),
         revisions: createRevisions(1),
         messages: createChangeMessages(1),
       };
       element._change.labels = {};
-      sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+      stubRestApi('getChangeDetail').callsFake(() =>
         Promise.resolve({
-          ...createChange(),
+          ...createChangeViewChange(),
           // element has latest info
           revisions: createRevisions(1),
           messages: createChangeMessages(1),
@@ -730,7 +721,7 @@
       element._loggedIn = true;
       element._loading = false;
       element._change = {
-        ...createChange(),
+        ...createChangeViewChange(),
         labels: {},
         actions: {
           abandon: {
@@ -757,7 +748,7 @@
       element._loggedIn = true;
       element._loading = false;
       element._change = {
-        ...createChange(),
+        ...createChangeViewChange(),
         labels: {},
         actions: {
           abandon: {
@@ -841,10 +832,10 @@
       element._changeNum = TEST_NUMERIC_CHANGE_ID;
       element._patchRange = {
         basePatchNum: ParentPatchSetNum,
-        patchNum: 1 as PatchSetNum,
+        patchNum: 1 as RevisionPatchSetNum,
       };
       element._change = {
-        ...createChange(),
+        ...createChangeViewChange(),
         revisions: {
           rev1: createRevision(),
         },
@@ -909,7 +900,9 @@
   });
 
   suite('reloading drafts', () => {
-    let reloadStub: SinonStubbedMember<typeof element.$.commentAPI.reloadDrafts>;
+    let reloadStub: SinonStubbedMember<
+      typeof element.$.commentAPI.reloadDrafts
+    >;
     const drafts: {[path: string]: UIDraft[]} = {
       'testfile.txt': [
         {
@@ -965,7 +958,7 @@
   suite('_recomputeComments', () => {
     setup(() => {
       element._changeNum = TEST_NUMERIC_CHANGE_ID;
-      element._change = createChange();
+      element._change = createChangeViewChange();
       flush();
       // Fake computeDraftCount as its required for ChangeComments,
       // see gr-comment-api#reloadDrafts.
@@ -976,7 +969,7 @@
           computeDraftCount: () => 0,
         } as ChangeComments)
       );
-      element._change = createChange();
+      element._change = createChangeViewChange();
       element._changeNum = element._change._number;
     });
 
@@ -1084,10 +1077,10 @@
       element._changeNum = TEST_NUMERIC_CHANGE_ID;
       element._patchRange = {
         basePatchNum: ParentPatchSetNum,
-        patchNum: 1 as PatchSetNum,
+        patchNum: 1 as RevisionPatchSetNum,
       };
       element._change = {
-        ...createChange(),
+        ...createChangeViewChange(),
         revisions: {
           rev2: createRevision(2),
           rev1: createRevision(1),
@@ -1105,7 +1098,10 @@
           },
         },
       };
-      sinon.stub(element.$.relatedChanges, 'reload');
+      const relatedChanges = element.shadowRoot!.querySelector(
+        '#relatedChanges'
+      ) as GrRelatedChangesList;
+      sinon.stub(relatedChanges, 'reload');
       sinon.stub(element, '_reload').returns(Promise.resolve([]));
       sinon.spy(element, '_paramsChanged');
       element.params = createAppElementChangeViewParams();
@@ -1116,7 +1112,7 @@
     setup(done => {
       element._changeNum = TEST_NUMERIC_CHANGE_ID;
       element._change = {
-        ...createChange(),
+        ...createChangeViewChange(),
         revisions: {
           rev2: createRevision(2),
           rev1: createRevision(1),
@@ -1240,7 +1236,7 @@
   test('_changeStatuses', () => {
     element._loading = false;
     element._change = {
-      ...createChange(),
+      ...createChangeViewChange(),
       revisions: {
         rev2: createRevision(2),
         rev1: createRevision(1),
@@ -1262,7 +1258,6 @@
     element._mergeable = true;
     const expectedStatuses = ['Merged', 'WIP'];
     assert.deepEqual(element._changeStatuses, expectedStatuses);
-    assert.equal(element._changeStatus, expectedStatuses.join(', '));
     flush();
     const statusChips = element.shadowRoot!.querySelectorAll(
       'gr-change-status'
@@ -1311,10 +1306,10 @@
     element._changeNum = TEST_NUMERIC_CHANGE_ID;
     element._patchRange = {
       basePatchNum: ParentPatchSetNum,
-      patchNum: 1 as PatchSetNum,
+      patchNum: 1 as RevisionPatchSetNum,
     };
     const change = {
-      ...createChange(),
+      ...createChangeViewChange(),
       owner: createAccountWithIdNameAndEmail(),
       revisions: {
         rev2: createRevision(2),
@@ -1373,7 +1368,7 @@
   test('comment events properly update diff drafts', () => {
     element._patchRange = {
       basePatchNum: ParentPatchSetNum,
-      patchNum: 2 as PatchSetNum,
+      patchNum: 2 as RevisionPatchSetNum,
     };
     const draft: DraftInfo = {
       __draft: true,
@@ -1413,15 +1408,17 @@
   });
 
   test('change num change', () => {
+    const change = {
+      ...createChangeViewChange(),
+      labels: {},
+    } as ParsedChangeInfo;
+    stubRestApi('getChangeDetail').returns(Promise.resolve(change));
     element._changeNum = undefined;
     element._patchRange = {
       basePatchNum: ParentPatchSetNum,
-      patchNum: 2 as PatchSetNum,
+      patchNum: 2 as RevisionPatchSetNum,
     };
-    element._change = {
-      ...createChange(),
-      labels: {},
-    };
+    element._change = change;
     element.viewState.changeNum = null;
     element.viewState.diffMode = DiffViewMode.UNIFIED;
     assert.equal(element.viewState.numFilesShown, 200);
@@ -1467,9 +1464,7 @@
   });
 
   test('diffMode defaults to side by side without preferences', done => {
-    sinon
-      .stub(element.$.restAPI, 'getPreferences')
-      .returns(Promise.resolve(createPreferences()));
+    stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
     // No user prefs or diff view mode set.
 
     element._setDiffViewMode()!.then(() => {
@@ -1479,7 +1474,7 @@
   });
 
   test('diffMode defaults to preference when not already set', done => {
-    sinon.stub(element.$.restAPI, 'getPreferences').returns(
+    stubRestApi('getPreferences').returns(
       Promise.resolve({
         ...createPreferences(),
         default_diff_view: DiffViewMode.UNIFIED,
@@ -1494,7 +1489,7 @@
 
   test('existing diffMode overrides preference', done => {
     element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
-    sinon.stub(element.$.restAPI, 'getPreferences').returns(
+    stubRestApi('getPreferences').returns(
       Promise.resolve({
         ...createPreferences(),
         default_diff_view: DiffViewMode.UNIFIED,
@@ -1512,30 +1507,54 @@
       .callsFake(() => Promise.resolve([]));
     const reloadPatchDependentStub = sinon
       .stub(element, '_reloadPatchNumDependentResources')
-      .callsFake(() => Promise.resolve([undefined, undefined]));
-    const relatedClearSpy = sinon.spy(element.$.relatedChanges, 'clear');
+      .callsFake(() => Promise.resolve([undefined, undefined, undefined]));
+    flush();
     const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
 
     const value: AppElementChangeViewParams = {
       ...createAppElementChangeViewParams(),
       view: GerritView.CHANGE,
-      patchNum: 1 as PatchSetNum,
+      patchNum: 1 as RevisionPatchSetNum,
     };
     element._paramsChanged(value);
     assert.isTrue(reloadStub.calledOnce);
-    assert.isTrue(relatedClearSpy.calledOnce);
 
     element._initialLoadComplete = true;
 
-    value.basePatchNum = 1 as PatchSetNum;
-    value.patchNum = 2 as PatchSetNum;
+    value.basePatchNum = 1 as BasePatchSetNum;
+    value.patchNum = 2 as RevisionPatchSetNum;
     element._paramsChanged(value);
     assert.isFalse(reloadStub.calledTwice);
     assert.isTrue(reloadPatchDependentStub.calledOnce);
-    assert.isTrue(relatedClearSpy.calledOnce);
     assert.isTrue(collapseStub.calledTwice);
   });
 
+  test('reload ported comments when patchNum changes', () => {
+    sinon.stub(element, '_reload').callsFake(() => Promise.resolve([]));
+    sinon.stub(element, '_getCommitInfo');
+    sinon.stub(element.$.fileList, 'reload');
+    flush();
+    const reloadPortedCommentsStub = sinon.stub(
+      element.$.commentAPI,
+      'reloadPortedComments'
+    );
+    sinon.stub(element.$.fileList, 'collapseAllDiffs');
+
+    const value: AppElementChangeViewParams = {
+      ...createAppElementChangeViewParams(),
+      view: GerritView.CHANGE,
+      patchNum: 1 as RevisionPatchSetNum,
+    };
+    element._paramsChanged(value);
+
+    element._initialLoadComplete = true;
+
+    value.basePatchNum = 1 as BasePatchSetNum;
+    value.patchNum = 2 as RevisionPatchSetNum;
+    element._paramsChanged(value);
+    assert.isTrue(reloadPortedCommentsStub.calledOnce);
+  });
+
   test('reload entire page when patchRange doesnt change', () => {
     const reloadStub = sinon
       .stub(element, '_reload')
@@ -1552,36 +1571,20 @@
 
   test('related changes are not updated after other action', done => {
     sinon.stub(element, '_reload').callsFake(() => Promise.resolve([]));
-    sinon.stub(element.$.relatedChanges, 'reload');
+    flush();
+    const relatedChanges = element.shadowRoot!.querySelector(
+      '#relatedChanges'
+    ) as GrRelatedChangesList;
+    sinon.stub(relatedChanges, 'reload');
     element._reload(true).then(() => {
       assert.isFalse(navigateToChangeStub.called);
       done();
     });
   });
 
-  test('_computeMergedCommitInfo', () => {
-    const dummyRevs: {[revisionId: string]: RevisionInfo} = {
-      1: createRevision(1),
-      2: createRevision(2),
-    };
-    assert.deepEqual(
-      element._computeMergedCommitInfo('0' as CommitId, dummyRevs),
-      {}
-    );
-    assert.deepEqual(
-      element._computeMergedCommitInfo('1' as CommitId, dummyRevs),
-      dummyRevs[1].commit
-    );
-
-    // Regression test for issue 5337.
-    const commit = element._computeMergedCommitInfo('2' as CommitId, dummyRevs);
-    assert.notDeepEqual(commit, dummyRevs[2]);
-    assert.deepEqual(commit, dummyRevs[2].commit);
-  });
-
   test('_computeCopyTextForTitle', () => {
     const change: ChangeInfo = {
-      ...createChange(),
+      ...createChangeViewChange(),
       _number: 123 as NumericChangeId,
       subject: 'test subject',
       revisions: {
@@ -1612,6 +1615,7 @@
       revisions: {
         rev1: createRevision(1),
       },
+      current_revision: undefined,
     };
     assert.equal(element._getLatestRevisionSHA(change), 'rev1');
   });
@@ -1619,7 +1623,7 @@
   test('show commit message edit button', () => {
     const change = createChange();
     const mergedChanged: ChangeInfo = {
-      ...createChange(),
+      ...createChangeViewChange(),
       status: ChangeStatus.MERGED,
     };
     assert.isTrue(element._computeHideEditCommitMessage(false, false, change));
@@ -1638,15 +1642,14 @@
   });
 
   test('_handleCommitMessageSave trims trailing whitespace', () => {
-    element._change = createChange();
+    element._change = createChangeViewChange();
     // Response code is 500, because we want to avoid window reloading
-    const putStub = sinon
-      .stub(element.$.restAPI, 'putChangeCommitMessage')
-      .returns(Promise.resolve(new Response(null, {status: 500})));
+    const putStub = stubRestApi('putChangeCommitMessage').returns(
+      Promise.resolve(new Response(null, {status: 500}))
+    );
 
-    const mockEvent = (content: string) => {
-      return new CustomEvent('', {detail: {content}});
-    };
+    const mockEvent = (content: string) =>
+      new CustomEvent('', {detail: {content}});
 
     element._handleCommitMessageSave(mockEvent('test \n  test '));
     assert.equal(putStub.lastCall.args[1], 'test\n  test');
@@ -1658,109 +1661,11 @@
     assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
   });
 
-  test('_computeChangeIdCommitMessageError', () => {
-    let commitMessage = 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
-    let change: ChangeInfo = {
-      ...createChange(),
-      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
-    };
-    assert.equal(
-      element._computeChangeIdCommitMessageError(commitMessage, change),
-      null
-    );
-
-    change = {
-      ...createChange(),
-      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
-    };
-    assert.equal(
-      element._computeChangeIdCommitMessageError(commitMessage, change),
-      'mismatch'
-    );
-
-    commitMessage = 'This is the greatest change.';
-    assert.equal(
-      element._computeChangeIdCommitMessageError(commitMessage, change),
-      'missing'
-    );
-  });
-
-  test('multiple change Ids in commit message picks last', () => {
-    const commitMessage = [
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
-    ].join('\n');
-    let change: ChangeInfo = {
-      ...createChange(),
-      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
-    };
-    assert.equal(
-      element._computeChangeIdCommitMessageError(commitMessage, change),
-      null
-    );
-    change = {
-      ...createChange(),
-      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
-    };
-    assert.equal(
-      element._computeChangeIdCommitMessageError(commitMessage, change),
-      'mismatch'
-    );
-  });
-
-  test('does not count change Id that starts mid line', () => {
-    const commitMessage = [
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
-    ].join(' and ');
-    let change: ChangeInfo = {
-      ...createChange(),
-      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
-    };
-    assert.equal(
-      element._computeChangeIdCommitMessageError(commitMessage, change),
-      null
-    );
-    change = {
-      ...createChange(),
-      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
-    };
-    assert.equal(
-      element._computeChangeIdCommitMessageError(commitMessage, change),
-      'mismatch'
-    );
-  });
-
-  test('_computeTitleAttributeWarning', () => {
-    let changeIdCommitMessageError = 'missing';
-    assert.equal(
-      element._computeTitleAttributeWarning(changeIdCommitMessageError),
-      'No Change-Id in commit message'
-    );
-
-    changeIdCommitMessageError = 'mismatch';
-    assert.equal(
-      element._computeTitleAttributeWarning(changeIdCommitMessageError),
-      'Change-Id mismatch'
-    );
-  });
-
-  test('_computeChangeIdClass', () => {
-    let changeIdCommitMessageError = 'missing';
-    assert.equal(element._computeChangeIdClass(changeIdCommitMessageError), '');
-
-    changeIdCommitMessageError = 'mismatch';
-    assert.equal(
-      element._computeChangeIdClass(changeIdCommitMessageError),
-      'warning'
-    );
-  });
-
   test('topic is coalesced to null', done => {
     sinon.stub(element, '_changeChanged');
-    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+    stubRestApi('getChangeDetail').returns(
       Promise.resolve({
-        ...createChange(),
+        ...createChangeViewChange(),
         labels: {},
         current_revision: 'foo' as CommitId,
         revisions: {foo: createRevision()},
@@ -1775,9 +1680,9 @@
 
   test('commit sha is populated from getChangeDetail', done => {
     sinon.stub(element, '_changeChanged');
-    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+    stubRestApi('getChangeDetail').callsFake(() =>
       Promise.resolve({
-        ...createChange(),
+        ...createChangeViewChange(),
         labels: {},
         current_revision: 'foo' as CommitId,
         revisions: {foo: createRevision()},
@@ -1793,9 +1698,9 @@
   test('edit is added to change', () => {
     sinon.stub(element, '_changeChanged');
     const changeRevision = createRevision();
-    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+    stubRestApi('getChangeDetail').callsFake(() =>
       Promise.resolve({
-        ...createChange(),
+        ...createChangeViewChange(),
         labels: {},
         current_revision: 'foo' as CommitId,
         revisions: {foo: {...changeRevision}},
@@ -1807,7 +1712,7 @@
     };
     sinon.stub(element, '_getEdit').callsFake(() =>
       Promise.resolve({
-        base_patch_set_number: 1 as PatchSetNum,
+        base_patch_set_number: 1 as BasePatchSetNum,
         commit: {...editCommit},
         base_revision: 'abc',
         ref: 'some/ref' as GitRef,
@@ -1829,7 +1734,7 @@
 
   test('_getBasePatchNum', () => {
     const _change: ChangeInfo = {
-      ...createChange(),
+      ...createChangeViewChange(),
       revisions: {
         '98da160735fb81604b4c40e93c368f380539dd0e': createRevision(),
       },
@@ -1845,7 +1750,7 @@
     };
 
     const _change2: ChangeInfo = {
-      ...createChange(),
+      ...createChangeViewChange(),
       revisions: {
         '98da160735fb81604b4c40e93c368f380539dd0e': {
           ...createRevision(1),
@@ -1867,7 +1772,7 @@
     };
     assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);
 
-    _patchRange.patchNum = 1 as PatchSetNum;
+    _patchRange.patchNum = 1 as RevisionPatchSetNum;
     assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
   });
 
@@ -1950,19 +1855,16 @@
   });
 
   test('revert dialog opened with revert param', done => {
-    sinon
-      .stub(element.$.restAPI, 'getLoggedIn')
-      .callsFake(() => Promise.resolve(true));
     const awaitPluginsLoadedStub = sinon
       .stub(getPluginLoader(), 'awaitPluginsLoaded')
       .callsFake(() => Promise.resolve());
 
     element._patchRange = {
       basePatchNum: ParentPatchSetNum,
-      patchNum: 2 as PatchSetNum,
+      patchNum: 2 as RevisionPatchSetNum,
     };
     element._change = {
-      ...createChange(),
+      ...createChangeViewChange(),
       revisions: {
         rev1: createRevision(1),
         rev2: createRevision(2),
@@ -1987,14 +1889,14 @@
   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(() => {
+      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();
+      element.handleScroll();
     });
 
     test('scrollTop is set correctly', () => {
@@ -2023,14 +1925,14 @@
     setup(() => {
       sinon.stub(element.$.replyDialog, '_draftChanged');
       element._change = {
-        ...createChange(),
+        ...createChangeViewChange(),
         revisions: createRevisions(1),
         messages: createChangeMessages(1),
       };
       element._change.labels = {};
-      sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+      stubRestApi('getChangeDetail').callsFake(() =>
         Promise.resolve({
-          ...createChange(),
+          ...createChangeViewChange(),
           // element has latest info
           revisions: {rev1: createRevision()},
           messages: createChangeMessages(1),
@@ -2112,353 +2014,6 @@
     });
   });
 
-  suite('commit message expand/collapse', () => {
-    setup(() => {
-      element._change = {
-        ...createChange(),
-        revisions: createRevisions(1),
-        messages: createChangeMessages(1),
-      };
-      element._change.labels = {};
-      sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
-        Promise.resolve({
-          ...createChange(),
-          // new patchset was uploaded
-          revisions: createRevisions(2),
-          current_revision: getCurrentRevision(2),
-          messages: createChangeMessages(1),
-        })
-      );
-    });
-
-    test('commitCollapseToggle hidden for short commit message', () => {
-      element._latestCommitMessage = '';
-      assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
-    });
-
-    test('commitCollapseToggle shown for long commit message', () => {
-      element._latestCommitMessage = _.times(31, String).join('\n');
-      assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
-    });
-
-    test('commitCollapseToggle functions', () => {
-      element._latestCommitMessage = _.times(35, String).join('\n');
-      assert.isTrue(element._commitCollapsed);
-      assert.isTrue(element._commitCollapsible);
-      assert.isTrue(element.$.commitMessageEditor.hasAttribute('collapsed'));
-      tap(element.$.commitCollapseToggleButton);
-      assert.isFalse(element._commitCollapsed);
-      assert.isTrue(element._commitCollapsible);
-      assert.isFalse(element.$.commitMessageEditor.hasAttribute('collapsed'));
-    });
-  });
-
-  suite('related changes expand/collapse', () => {
-    let updateHeightSpy: SinonSpyMember<typeof element._updateRelatedChangeMaxHeight>;
-    setup(() => {
-      updateHeightSpy = sinon.spy(element, '_updateRelatedChangeMaxHeight');
-    });
-
-    test('relatedChangesToggle shown height greater than changeInfo height', () => {
-      assert.isFalse(
-        element.$.relatedChangesToggle.classList.contains('showToggle')
-      );
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(element, '_getScrollHeight').callsFake(() => 60);
-      sinon.stub(element, '_getLineHeight').callsFake(() => 5);
-      sinon.stub(window, 'matchMedia').callsFake(() => {
-        return {matches: true} as MediaQueryList;
-      });
-      element.$.relatedChanges.dispatchEvent(
-        new CustomEvent('new-section-loaded')
-      );
-      assert.isTrue(
-        element.$.relatedChangesToggle.classList.contains('showToggle')
-      );
-      assert.equal(updateHeightSpy.callCount, 1);
-    });
-
-    test('relatedChangesToggle hidden height less than changeInfo height', () => {
-      assert.isFalse(
-        element.$.relatedChangesToggle.classList.contains('showToggle')
-      );
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(element, '_getScrollHeight').callsFake(() => 40);
-      sinon.stub(element, '_getLineHeight').callsFake(() => 5);
-      sinon.stub(window, 'matchMedia').callsFake(() => {
-        return {matches: true} as MediaQueryList;
-      });
-      element.$.relatedChanges.dispatchEvent(
-        new CustomEvent('new-section-loaded')
-      );
-      assert.isFalse(
-        element.$.relatedChangesToggle.classList.contains('showToggle')
-      );
-      assert.equal(updateHeightSpy.callCount, 1);
-    });
-
-    test('relatedChangesToggle functions', () => {
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(window, 'matchMedia').callsFake(() => {
-        return {matches: false} as MediaQueryList;
-      });
-      assert.isTrue(element._relatedChangesCollapsed);
-      assert.isTrue(element.$.relatedChanges.classList.contains('collapsed'));
-      tap(element.$.relatedChangesToggleButton);
-      assert.isFalse(element._relatedChangesCollapsed);
-      assert.isFalse(element.$.relatedChanges.classList.contains('collapsed'));
-    });
-
-    test('_updateRelatedChangeMaxHeight without commit toggle', () => {
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
-      sinon.stub(window, 'matchMedia').callsFake(() => {
-        return {matches: false} as MediaQueryList;
-      });
-
-      // 50 (existing height) - 30 (extra height) = 20 (adjusted height).
-      // 20 (max existing height)  % 12 (line height) = 6 (remainder).
-      // 20 (adjusted height) - 8 (remainder) = 12 (max height to set).
-
-      element._updateRelatedChangeMaxHeight();
-      assert.equal(getCustomCssValue('--relation-chain-max-height'), '12px');
-      assert.equal(getCustomCssValue('--related-change-btn-top-padding'), '');
-    });
-
-    test('_updateRelatedChangeMaxHeight with commit toggle', () => {
-      element._latestCommitMessage = _.times(31, String).join('\n');
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
-      sinon.stub(window, 'matchMedia').callsFake(() => {
-        return {matches: false} as MediaQueryList;
-      });
-
-      // 50 (existing height) % 12 (line height) = 2 (remainder).
-      // 50 (existing height)  - 2 (remainder) = 48 (max height to set).
-
-      element._updateRelatedChangeMaxHeight();
-      assert.equal(getCustomCssValue('--relation-chain-max-height'), '48px');
-      assert.equal(
-        getCustomCssValue('--related-change-btn-top-padding'),
-        '2px'
-      );
-    });
-
-    test('_updateRelatedChangeMaxHeight in small screen mode', () => {
-      element._latestCommitMessage = _.times(31, String).join('\n');
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
-      sinon.stub(window, 'matchMedia').callsFake(() => {
-        return {matches: true} as MediaQueryList;
-      });
-
-      element._updateRelatedChangeMaxHeight();
-
-      // 400 (new height) % 12 (line height) = 4 (remainder).
-      // 400 (new height) - 4 (remainder) = 396.
-
-      assert.equal(getCustomCssValue('--relation-chain-max-height'), '396px');
-    });
-
-    test('_updateRelatedChangeMaxHeight in medium screen mode', () => {
-      element._latestCommitMessage = _.times(31, String).join('\n');
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
-      const matchMediaStub = sinon.stub(window, 'matchMedia').callsFake(() => {
-        if (matchMediaStub.lastCall.args[0] === '(max-width: 75em)') {
-          return {matches: true} as MediaQueryList;
-        } else {
-          return {matches: false} as MediaQueryList;
-        }
-      });
-
-      // 100 (new height) % 12 (line height) = 4 (remainder).
-      // 100 (new height) - 4 (remainder) = 96.
-      element._updateRelatedChangeMaxHeight();
-      assert.equal(getCustomCssValue('--relation-chain-max-height'), '96px');
-    });
-
-    suite('update checks', () => {
-      let startUpdateCheckTimerSpy: SinonSpyMember<typeof element._startUpdateCheckTimer>;
-      let asyncStub: SinonStubbedMember<typeof element.async>;
-      setup(() => {
-        startUpdateCheckTimerSpy = sinon.spy(element, '_startUpdateCheckTimer');
-        asyncStub = sinon.stub(element, 'async').callsFake(f => {
-          // Only fire the async callback one time.
-          if (asyncStub.callCount > 1) {
-            return 1;
-          }
-          f.call(element);
-          return 1;
-        });
-        element._change = {
-          ...createChange(),
-          revisions: createRevisions(1),
-          messages: createChangeMessages(1),
-        };
-      });
-
-      test('_startUpdateCheckTimer negative delay', () => {
-        const getChangeDetailStub = sinon
-          .stub(element.$.restAPI, 'getChangeDetail')
-          .callsFake(() =>
-            Promise.resolve({
-              ...createChange(),
-              // element has latest info
-              revisions: {rev1: createRevision()},
-              messages: createChangeMessages(1),
-              current_revision: 'rev1' as CommitId,
-            })
-          );
-
-        element._serverConfig = {
-          ...createServerInfo(),
-          change: {...createChangeConfig(), update_delay: -1},
-        };
-
-        assert.isTrue(startUpdateCheckTimerSpy.called);
-        assert.isFalse(getChangeDetailStub.called);
-      });
-
-      test('_startUpdateCheckTimer up-to-date', async () => {
-        const getChangeDetailStub = sinon
-          .stub(element.$.restAPI, 'getChangeDetail')
-          .callsFake(() =>
-            Promise.resolve({
-              ...createChange(),
-              // element has latest info
-              revisions: {rev1: createRevision()},
-              messages: createChangeMessages(1),
-              current_revision: 'rev1' as CommitId,
-            })
-          );
-
-        element._serverConfig = {
-          ...createServerInfo(),
-          change: {...createChangeConfig(), update_delay: 12345},
-        };
-        await flush();
-
-        assert.equal(startUpdateCheckTimerSpy.callCount, 2);
-        assert.isTrue(getChangeDetailStub.called);
-        assert.equal(asyncStub.lastCall.args[1], 12345 * 1000);
-      });
-
-      test('_startUpdateCheckTimer out-of-date shows an alert', done => {
-        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
-          Promise.resolve({
-            ...createChange(),
-            // new patchset was uploaded
-            revisions: createRevisions(2),
-            current_revision: getCurrentRevision(2),
-            messages: createChangeMessages(1),
-          })
-        );
-
-        element.addEventListener('show-alert', e => {
-          assert.equal(e.detail.message, 'A newer patch set has been uploaded');
-          done();
-        });
-        element._serverConfig = {
-          ...createServerInfo(),
-          change: {...createChangeConfig(), update_delay: 12345},
-        };
-
-        assert.equal(startUpdateCheckTimerSpy.callCount, 1);
-      });
-
-      test('_startUpdateCheckTimer respects _loading', async () => {
-        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
-          Promise.resolve({
-            ...createChange(),
-            // new patchset was uploaded
-            revisions: createRevisions(2),
-            current_revision: getCurrentRevision(2),
-            messages: createChangeMessages(1),
-          })
-        );
-
-        element._loading = true;
-        element._serverConfig = {
-          ...createServerInfo(),
-          change: {...createChangeConfig(), update_delay: 12345},
-        };
-        await flush();
-
-        // No toast, instead a second call to _startUpdateCheckTimer().
-        assert.equal(startUpdateCheckTimerSpy.callCount, 2);
-      });
-
-      test('_startUpdateCheckTimer new status shows an alert', done => {
-        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
-          Promise.resolve({
-            ...createChange(),
-            // element has latest info
-            revisions: {rev1: createRevision()},
-            messages: createChangeMessages(1),
-            current_revision: 'rev1' as CommitId,
-            status: ChangeStatus.MERGED,
-          })
-        );
-
-        element.addEventListener('show-alert', e => {
-          assert.equal(e.detail.message, 'This change has been merged');
-          done();
-        });
-        element._serverConfig = {
-          ...createServerInfo(),
-          change: {...createChangeConfig(), update_delay: 12345},
-        };
-      });
-
-      test('_startUpdateCheckTimer new messages shows an alert', done => {
-        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
-          Promise.resolve({
-            ...createChange(),
-            revisions: {rev1: createRevision()},
-            // element has new message
-            messages: createChangeMessages(2),
-            current_revision: 'rev1' as CommitId,
-          })
-        );
-        element.addEventListener('show-alert', e => {
-          assert.equal(
-            e.detail.message,
-            'There are new messages on this change'
-          );
-          done();
-        });
-        element._serverConfig = {
-          ...createServerInfo(),
-          change: {...createChangeConfig(), update_delay: 12345},
-        };
-      });
-    });
-
-    test('canStartReview computation', () => {
-      const change1: ChangeInfo = createChange();
-      const change2: ChangeInfo = {
-        ...createChange(),
-        actions: {
-          ready: {
-            enabled: true,
-          },
-        },
-      };
-      const change3: ChangeInfo = {
-        ...createChange(),
-        actions: {
-          ready: {
-            label: 'Ready for Review',
-          },
-        },
-      };
-      assert.isFalse(element._computeCanStartReview(change1));
-      assert.isTrue(element._computeCanStartReview(change2));
-      assert.isFalse(element._computeCanStartReview(change3));
-    });
-  });
-
   test('header class computation', () => {
     assert.equal(element._computeHeaderClass(), 'header');
     assert.equal(element._computeHeaderClass(true), 'header editMode');
@@ -2480,7 +2035,11 @@
   });
 
   test('topic update reloads related changes', () => {
-    const reloadStub = sinon.stub(element.$.relatedChanges, 'reload');
+    flush();
+    const relatedChanges = element.shadowRoot!.querySelector(
+      '#relatedChanges'
+    ) as GrRelatedChangesList;
+    const reloadStub = sinon.stub(relatedChanges, 'reload');
     element.dispatchEvent(new CustomEvent('topic-changed'));
     assert.isTrue(reloadStub.calledOnce);
   });
@@ -2496,25 +2055,19 @@
       );
     assert.isTrue(
       callCompute(
-        {basePatchNum: ParentPatchSetNum, patchNum: 1 as PatchSetNum},
+        {basePatchNum: ParentPatchSetNum, patchNum: 1 as RevisionPatchSetNum},
         {...createAppElementChangeViewParams(), edit: true}
       )
     );
     assert.isFalse(
       callCompute(
-        {basePatchNum: ParentPatchSetNum, patchNum: 1 as PatchSetNum},
-        createAppElementChangeViewParams()
-      )
-    );
-    assert.isFalse(
-      callCompute(
-        {basePatchNum: EditPatchSetNum, patchNum: 1 as PatchSetNum},
+        {basePatchNum: ParentPatchSetNum, patchNum: 1 as RevisionPatchSetNum},
         createAppElementChangeViewParams()
       )
     );
     assert.isTrue(
       callCompute(
-        {basePatchNum: 1 as PatchSetNum, patchNum: EditPatchSetNum},
+        {basePatchNum: 1 as BasePatchSetNum, patchNum: EditPatchSetNum},
         createAppElementChangeViewParams()
       )
     );
@@ -2523,7 +2076,7 @@
   test('_processEdit', () => {
     element._patchRange = {};
     const change: ParsedChangeInfo = {
-      ...createChange(),
+      ...createChangeViewChange(),
       current_revision: 'foo' as CommitId,
       revisions: {
         foo: {...createRevision(), actions: {cherrypick: {enabled: true}}},
@@ -2544,7 +2097,7 @@
     const edit: EditInfo = {
       ref: 'ref/test/abc' as GitRef,
       base_revision: 'abc',
-      base_patch_set_number: 1 as PatchSetNum,
+      base_patch_set_number: 1 as BasePatchSetNum,
       commit: {...editCommit},
       fetch: {},
     };
@@ -2565,20 +2118,20 @@
     );
 
     // If _patchRange.patchNum is defined, do not load edit.
-    element._patchRange.patchNum = 5 as PatchSetNum;
+    element._patchRange.patchNum = 5 as RevisionPatchSetNum;
     change.current_revision = 'baz' as CommitId;
     element._processEdit((mockChange = _.cloneDeep(change)), edit);
-    assert.equal(element._patchRange.patchNum, 5 as PatchSetNum);
+    assert.equal(element._patchRange.patchNum, 5 as RevisionPatchSetNum);
     assert.notOk(mockChange.revisions.bar.actions);
   });
 
   test('file-action-tap handling', () => {
     element._patchRange = {
       basePatchNum: ParentPatchSetNum,
-      patchNum: 1 as PatchSetNum,
+      patchNum: 1 as RevisionPatchSetNum,
     };
     element._change = {
-      ...createChange(),
+      ...createChangeViewChange(),
     };
     const fileList = element.$.fileList;
     const Actions = GrEditConstants.Actions;
@@ -2654,9 +2207,9 @@
   test('_selectedRevision updates when patchNum is changed', () => {
     const revision1: RevisionInfo = createRevision(1);
     const revision2: RevisionInfo = createRevision(2);
-    sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
+    stubRestApi('getChangeDetail').returns(
       Promise.resolve({
-        ...createChange(),
+        ...createChangeViewChange(),
         revisions: {
           aaa: revision1,
           bbb: revision2,
@@ -2670,7 +2223,7 @@
     sinon
       .stub(element, '_getPreferences')
       .returns(Promise.resolve(createPreferences()));
-    element._patchRange = {patchNum: 2 as PatchSetNum};
+    element._patchRange = {patchNum: 2 as RevisionPatchSetNum};
     return element._getChangeDetail().then(() => {
       assert.strictEqual(element._selectedRevision, revision2);
 
@@ -2683,9 +2236,9 @@
     const revision1 = createRevision(1);
     const revision2 = createRevision(2);
     const revision3 = createEditRevision();
-    sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
+    stubRestApi('getChangeDetail').returns(
       Promise.resolve({
-        ...createChange(),
+        ...createChangeViewChange(),
         revisions: {
           aaa: revision1,
           bbb: revision2,
@@ -2707,11 +2260,11 @@
   });
 
   test('_sendShowChangeEvent', () => {
-    const change = {...createChange(), labels: {}};
+    const change = {...createChangeViewChange(), labels: {}};
     element._change = {...change};
-    element._patchRange = {patchNum: 4 as PatchSetNum};
+    element._patchRange = {patchNum: 4 as RevisionPatchSetNum};
     element._mergeable = true;
-    const showStub = sinon.stub(element.$.jsAPI, 'handleEvent');
+    const showStub = sinon.stub(appContext.jsApiService, 'handleEvent');
     element._sendShowChangeEvent();
     assert.isTrue(showStub.calledOnce);
     assert.equal(showStub.lastCall.args[0], EventType.SHOW_CHANGE);
@@ -2732,7 +2285,7 @@
       navigateToChangeStub.restore();
 
       element._change = {
-        ...createChange(),
+        ...createChangeViewChange(),
         revisions: {rev1: createRevision()},
       };
     });
@@ -2745,7 +2298,7 @@
       });
 
       element.set('_change.revisions.rev2', {
-        _number: SPECIAL_PATCH_SET_NUM.EDIT,
+        _number: EditPatchSetNum,
       });
       flush();
 
@@ -2761,7 +2314,7 @@
       });
 
       element.set('_change.revisions.rev2', {_number: 2});
-      element._patchRange = {patchNum: 1 as PatchSetNum};
+      element._patchRange = {patchNum: 1 as RevisionPatchSetNum};
       flush();
 
       fireEdit();
@@ -2777,7 +2330,7 @@
       });
 
       element.set('_change.revisions.rev2', {_number: 2});
-      element._patchRange = {patchNum: 2 as PatchSetNum};
+      element._patchRange = {patchNum: 2 as RevisionPatchSetNum};
       flush();
 
       fireEdit();
@@ -2786,7 +2339,7 @@
 
   test('_handleStopEditTap', done => {
     element._change = {
-      ...createChange(),
+      ...createChangeViewChange(),
     };
     sinon.stub(element.$.metadata, '_computeLabelNames');
     navigateToChangeStub.restore();
@@ -2796,7 +2349,7 @@
       done();
     });
 
-    element._patchRange = {patchNum: 1 as PatchSetNum};
+    element._patchRange = {patchNum: 1 as RevisionPatchSetNum};
     element.$.actions.dispatchEvent(
       new CustomEvent('stop-edit-tap', {bubbles: false})
     );
@@ -2804,7 +2357,7 @@
 
   suite('plugin endpoints', () => {
     test('endpoint params', done => {
-      element._change = {...createChange(), labels: {}};
+      element._change = {...createChangeViewChange(), labels: {}};
       element._selectedRevision = createRevision();
       let hookEl: HTMLElement;
       let plugin: PluginApi;
@@ -2817,7 +2370,7 @@
             .then(el => (hookEl = el));
         },
         '0.1',
-        'http://some/plugins/url.html'
+        'http://some/plugins/url.js'
       );
       flush(() => {
         assert.strictEqual((hookEl as any).plugin, plugin);
@@ -2831,10 +2384,10 @@
   suite('_getMergeability', () => {
     let getMergeableStub: SinonStubbedMember<RestApiService['getMergeable']>;
     setup(() => {
-      element._change = {...createChange(), labels: {}};
-      getMergeableStub = sinon
-        .stub(element.$.restAPI, 'getMergeable')
-        .returns(Promise.resolve({...createMergeable(), mergeable: true}));
+      element._change = {...createChangeViewChange(), labels: {}};
+      getMergeableStub = stubRestApi('getMergeable').returns(
+        Promise.resolve({...createMergeable(), mergeable: true})
+      );
     });
 
     test('merged change', () => {
@@ -2865,23 +2418,25 @@
   });
 
   test('_paramsChanged sets in projectLookup', () => {
-    sinon.stub(element.$.relatedChanges, 'reload');
+    flush();
+    const relatedChanges = element.shadowRoot!.querySelector(
+      '#relatedChanges'
+    ) as GrRelatedChangesList;
+    sinon.stub(relatedChanges, 'reload');
     sinon.stub(element, '_reload').returns(Promise.resolve([]));
-    const setStub = sinon.stub(element.$.restAPI, 'setInProjectLookup');
+    const setStub = stubRestApi('setInProjectLookup');
     element._paramsChanged({
       view: GerritNav.View.CHANGE,
       changeNum: 101 as NumericChangeId,
       project: TEST_PROJECT_NAME,
     });
     assert.isTrue(setStub.calledOnce);
-    assert.isTrue(
-      setStub.calledWith(101 as NumericChangeId, TEST_PROJECT_NAME)
-    );
+    assert.isTrue(setStub.calledWith(101 as never, TEST_PROJECT_NAME as never));
   });
 
   test('_handleToggleStar called when star is tapped', () => {
     element._change = {
-      ...createChange(),
+      ...createChangeViewChange(),
       owner: {_account_id: 1 as AccountId},
       starred: false,
     };
@@ -2897,22 +2452,25 @@
     setup(() => {
       element._patchRange = {
         basePatchNum: ParentPatchSetNum,
-        patchNum: 1 as PatchSetNum,
+        patchNum: 1 as RevisionPatchSetNum,
       };
       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
+        .stub(element, '_reloadPatchNumDependentResources')
+        .returns(Promise.resolve([undefined, undefined, undefined]));
     });
 
-    test("don't report changedDisplayed on reply", done => {
+    test("don't report changeDisplayed on reply", done => {
       const changeDisplayStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeDisplayed'
       );
       const changeFullyLoadedStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeFullyLoaded'
       );
       element._handleReplySent();
@@ -2923,13 +2481,13 @@
       });
     });
 
-    test('report changedDisplayed on _paramsChanged', done => {
+    test('report changeDisplayed on _paramsChanged', done => {
       const changeDisplayStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeDisplayed'
       );
       const changeFullyLoadedStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeFullyLoaded'
       );
       element._paramsChanged({
@@ -2944,4 +2502,23 @@
       });
     });
   });
+
+  test('_calculateHasParent', () => {
+    const changeId = '123' as ChangeId;
+    const relatedChanges: RelatedChangeAndCommitInfo[] = [];
+
+    assert.equal(element._calculateHasParent(changeId, relatedChanges), false);
+
+    relatedChanges.push({
+      ...createRelatedChangeAndCommitInfo(),
+      change_id: '123' as ChangeId,
+    });
+    assert.equal(element._calculateHasParent(changeId, relatedChanges), false);
+
+    relatedChanges.push({
+      ...createRelatedChangeAndCommitInfo(),
+      change_id: '234' as ChangeId,
+    });
+    assert.equal(element._calculateHasParent(changeId, relatedChanges), true);
+  });
 });
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 18bd3a0..3ba12b43 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
@@ -16,8 +16,6 @@
  */
 import '../../../styles/shared-styles';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-commit-info_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
@@ -31,9 +29,7 @@
 }
 
 @customElement('gr-commit-info')
-export class GrCommitInfo extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCommitInfo extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
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 10563ee..0954b7f 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
@@ -17,8 +17,6 @@
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-abandon-dialog_html';
 import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
@@ -37,12 +35,9 @@
   }
 }
 
-/**
- * @extends PolymerElement
- */
 @customElement('gr-confirm-abandon-dialog')
 export class GrConfirmAbandonDialog extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  PolymerElement
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
index 2f33858..75b6053 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
@@ -16,8 +16,6 @@
  */
 import '../../../styles/shared-styles';
 import '../../shared/gr-dialog/gr-dialog';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-cherrypick-conflict-dialog_html';
 import {customElement} from '@polymer/decorators';
@@ -29,9 +27,7 @@
 }
 
 @customElement('gr-confirm-cherrypick-conflict-dialog')
-export class GrConfirmCherrypickConflictDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrConfirmCherrypickConflictDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index e05bac0..af565cb 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -19,9 +19,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-cherrypick-dialog_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
@@ -32,16 +29,14 @@
   RepoName,
   BranchName,
   CommitId,
+  ChangeInfoId,
 } from '../../../types/common';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {customElement, property, observe} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {
-  GrAutocomplete,
-  AutocompleteSuggestion,
-} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {HttpMethod, ChangeStatus} from '../../../constants/constants';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {fireEvent} from '../../../utils/event-util';
 
 const SUGGESTIONS_LIMIT = 15;
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -50,11 +45,18 @@
   TOPIC,
 }
 
+// These values are directly displayed in the dialog to show progress of change
+enum ProgressStatus {
+  RUNNING = 'RUNNING',
+  FAILED = 'FAILED',
+  NOT_STARTED = 'NOT STARTED',
+  SUCCESSFUL = 'SUCCESSFUL',
+}
+
 type Statuses = {[changeId: string]: Status};
 
-// TODO(TS): maybe convert status to an enum
 interface Status {
-  status: string;
+  status: ProgressStatus;
   msg?: string;
 }
 
@@ -64,19 +66,14 @@
   }
 }
 
-// TODO(TS): add type after gr-autocomplete and gr-rest-api-interface
-// is converted
 export interface GrConfirmCherrypickDialog {
   $: {
-    restAPI: RestApiService & Element;
-    branchInput: GrAutocomplete;
+    branchInput: HTMLElement;
   };
 }
 
 @customElement('gr-confirm-cherrypick-dialog')
-export class GrConfirmCherrypickDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrConfirmCherrypickDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -142,6 +139,10 @@
   @property({type: Object})
   reporting: ReportingService;
 
+  private selectedChangeIds = new Set<ChangeInfoId>();
+
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._statuses = {};
@@ -155,6 +156,7 @@
     const projects: {[projectName: string]: boolean} = {};
     this._duplicateProjectChanges = false;
     changes.forEach(change => {
+      this.selectedChangeIds.add(change.id);
       if (projects[change.project]) {
         this._duplicateProjectChanges = true;
       }
@@ -172,6 +174,19 @@
     );
   }
 
+  _isChangeSelected(changeId: ChangeInfoId) {
+    return this.selectedChangeIds.has(changeId);
+  }
+
+  _toggleChangeSelected(e: Event) {
+    const changeId = ((dom(e) as EventApi).localTarget as HTMLElement).dataset[
+      'item'
+    ]! as ChangeInfoId;
+    if (this.selectedChangeIds.has(changeId))
+      this.selectedChangeIds.delete(changeId);
+    else this.selectedChangeIds.add(changeId);
+  }
+
   _computeTopicErrorMessage(duplicateProjectChanges: boolean) {
     if (duplicateProjectChanges) {
       return 'Two changes cannot be of the same project';
@@ -184,18 +199,19 @@
   }
 
   _computeStatus(change: ChangeInfo, statuses: Statuses) {
-    if (!change || !statuses || !statuses[change.id]) return 'NOT STARTED';
+    if (!change || !statuses || !statuses[change.id])
+      return ProgressStatus.NOT_STARTED;
     return statuses[change.id].status;
   }
 
   _computeStatusClass(change: ChangeInfo, statuses: Statuses) {
     if (!change || !statuses || !statuses[change.id]) return '';
-    return statuses[change.id].status === 'FAILED' ? 'error' : '';
+    return statuses[change.id].status === ProgressStatus.FAILED ? 'error' : '';
   }
 
   _computeError(change: ChangeInfo, statuses: Statuses) {
     if (!change || !statuses || !statuses[change.id]) return '';
-    if (statuses[change.id].status === 'FAILED') {
+    if (statuses[change.id].status === ProgressStatus.FAILED) {
       return statuses[change.id].msg;
     }
     return '';
@@ -213,7 +229,7 @@
 
   _computeCancelLabel(statuses: Statuses) {
     const isRunningChange = Object.values(statuses).some(
-      v => v.status === 'RUNNING'
+      v => v.status === ProgressStatus.RUNNING
     );
     return isRunningChange ? 'Close' : 'Cancel';
   }
@@ -221,14 +237,16 @@
   _computeDisableCherryPick(
     cherryPickType: CherryPickType,
     duplicateProjectChanges: boolean,
-    statuses: Statuses
+    statuses: Statuses,
+    branch?: BranchName
   ) {
+    if (!branch) return true;
     const duplicateProject =
       cherryPickType === CherryPickType.TOPIC && duplicateProjectChanges;
     if (duplicateProject) return true;
     if (!statuses) return false;
     const isRunningChange = Object.values(statuses).some(
-      v => v.status === 'RUNNING'
+      v => v.status === ProgressStatus.RUNNING
     );
     return isRunningChange;
   }
@@ -243,10 +261,12 @@
 
   _handlecherryPickSingleChangeClicked() {
     this._cherryPickType = CherryPickType.SINGLE_CHANGE;
+    fireEvent(this, 'iron-resize');
   }
 
   _handlecherryPickTopicClicked() {
     this._cherryPickType = CherryPickType.TOPIC;
+    fireEvent(this, 'iron-resize');
   }
 
   @observe('changeStatus', 'commitNum', 'commitMessage')
@@ -281,14 +301,22 @@
   _handleCherryPickFailed(change: ChangeInfo, response?: Response | null) {
     if (!response) return;
     response.text().then((errText: string) => {
-      this.updateStatus(change, {status: 'FAILED', msg: errText});
+      this.updateStatus(change, {status: ProgressStatus.FAILED, msg: errText});
     });
   }
 
   _handleCherryPickTopic() {
-    const topic = this._generateRandomCherryPickTopic(this.changes[0]);
-    this.changes.forEach(change => {
-      this.updateStatus(change, {status: 'RUNNING'});
+    const changes = this.changes.filter(change =>
+      this.selectedChangeIds.has(change.id)
+    );
+    if (!changes.length) {
+      const errorSpan = this.shadowRoot?.querySelector('.error-message');
+      errorSpan!.innerHTML = 'No change selected';
+      return;
+    }
+    const topic = this._generateRandomCherryPickTopic(changes[0]);
+    changes.forEach(change => {
+      this.updateStatus(change, {status: ProgressStatus.RUNNING});
       const payload = {
         destination: this.branch,
         base: null,
@@ -301,7 +329,7 @@
       };
       // revisions and current_revision must exist hence casting
       const patchNum = change.revisions![change.current_revision!]._number;
-      this.$.restAPI
+      this.restApiService
         .executeChangeAction(
           change._number,
           HttpMethod.POST,
@@ -311,9 +339,9 @@
           handleError
         )
         .then(() => {
-          this.updateStatus(change, {status: 'SUCCESSFUL'});
+          this.updateStatus(change, {status: ProgressStatus.SUCCESSFUL});
           const failedOrPending = Object.values(this._statuses).find(
-            v => v.status !== 'SUCCESSFUL'
+            v => v.status !== ProgressStatus.SUCCESSFUL
           );
           if (!failedOrPending) {
             /* This needs some more work, as the new topic may not always be
@@ -360,30 +388,25 @@
     input: string
   ): Promise<AutocompleteSuggestion[]> {
     if (!this.project) {
-      console.error('no project specified');
+      this.reporting.error(new Error('no project specified'));
       return Promise.resolve([]);
     }
     if (input.startsWith('refs/heads/')) {
       input = input.substring('refs/heads/'.length);
     }
-    return this.$.restAPI
+    return this.restApiService
       .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
       .then((response: BranchInfo[] | undefined) => {
-        const branches = [];
         if (!response) return [];
-        let branch;
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
-          }
-          if (response[key].ref.startsWith('refs/heads/')) {
-            branch = response[key].ref.substring('refs/heads/'.length);
+        const branches = [];
+        for (const branchInfo of response) {
+          let branch;
+          if (branchInfo.ref.startsWith('refs/heads/')) {
+            branch = branchInfo.ref.substring('refs/heads/'.length);
           } else {
-            branch = response[key].ref;
+            branch = branchInfo.ref;
           }
-          branches.push({
-            name: branch,
-          });
+          branches.push({name: branch});
         }
         return branches;
       });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
index 072f110..d42f7e5 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
@@ -46,11 +46,12 @@
     }
     .cherryPickTopicLayout {
       display: flex;
+      align-items: center;
+      margin-bottom: var(--spacing-m);
     }
     .cherryPickSingleChange,
     .cherryPickTopic {
       margin-left: var(--spacing-m);
-      margin-bottom: var(--spacing-m);
     }
     .cherry-pick-topic-message {
       margin-bottom: var(--spacing-m);
@@ -85,7 +86,7 @@
   <gr-dialog
     confirm-label="Cherry Pick"
     cancel-label="[[_computeCancelLabel(_statuses)]]"
-    disabled$="[[_computeDisableCherryPick(_cherryPickType, _duplicateProjectChanges, _statuses)]]"
+    disabled$="[[_computeDisableCherryPick(_cherryPickType, _duplicateProjectChanges, _statuses, branch)]]"
     on-confirm="_handleConfirmTap"
     on-cancel="_handleCancelTap"
   >
@@ -119,9 +120,7 @@
         </div></template
       >
 
-      <label for="branchInput">
-        Cherry Pick to branch
-      </label>
+      <label for="branchInput"> Cherry Pick to branch </label>
       <gr-autocomplete
         id="branchInput"
         text="{{branch}}"
@@ -152,9 +151,7 @@
             bind-value="{{baseCommit}}"
           />
         </iron-input>
-        <label for="messageInput">
-          Cherry Pick Commit Message
-        </label>
+        <label for="messageInput"> Cherry Pick Commit Message </label>
       </template>
       <template
         is="dom-if"
@@ -179,10 +176,12 @@
         <table>
           <thead>
             <tr>
+              <th></th>
               <th>Change</th>
+              <th>Status</th>
               <th>Subject</th>
               <th>Project</th>
-              <th>Status</th>
+              <th>Progress</th>
               <!-- Error Message -->
               <th></th>
             </tr>
@@ -190,7 +189,16 @@
           <tbody>
             <template is="dom-repeat" items="[[changes]]">
               <tr>
+                <td>
+                  <input
+                    type="checkbox"
+                    data-item$="[[item.id]]"
+                    on-change="_toggleChangeSelected"
+                    checked="[[_isChangeSelected(item.id)]]"
+                  />
+                </td>
                 <td><span> [[_getChangeId(item)]] </span></td>
+                <td><span> [[item.status]] </span></td>
                 <td>
                   <span> [[_getTrimmedChangeSubject(item.subject)]] </span>
                 </td>
@@ -212,5 +220,4 @@
       </template>
     </div>
   </gr-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
index 07f8f63..536a4ab 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-confirm-cherrypick-dialog.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-confirm-cherrypick-dialog');
 
@@ -28,20 +29,18 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getRepoBranches(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve([
-            {
-              ref: 'refs/heads/test-branch',
-              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-              can_delete: true,
-            },
-          ]);
-        } else {
-          return Promise.resolve({});
-        }
-      },
+    stubRestApi('getRepoBranches').callsFake(input => {
+      if (input.startsWith('test')) {
+        return Promise.resolve([
+          {
+            ref: 'refs/heads/test-branch',
+            revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+            can_delete: true,
+          },
+        ]);
+      } else {
+        return Promise.resolve([]);
+      }
     });
     element = basicFixture.instantiate();
     element.project = 'test-project';
@@ -78,16 +77,15 @@
     assert.equal(element.message, myNewMessage);
   });
 
-  test('_getProjectBranchesSuggestions empty', done => {
-    element._getProjectBranchesSuggestions('nonexistent').then(branches => {
-      assert.equal(branches.length, 0);
-      done();
-    });
+  test('_getProjectBranchesSuggestions empty', async () => {
+    const branches = await element._getProjectBranchesSuggestions('asdf');
+    assert.isEmpty(branches);
   });
 
   suite('cherry pick topic', () => {
     const changes = [
       {
+        id: '1234',
         change_id: '12345678901234', topic: 'T', subject: 'random',
         project: 'A',
         _number: 1,
@@ -97,6 +95,7 @@
         current_revision: 'a',
       },
       {
+        id: '5678',
         change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
         project: 'B',
         _number: 2,
@@ -109,11 +108,12 @@
     setup(() => {
       element.updateChanges(changes);
       element._cherryPickType = CHERRY_PICK_TYPES.TOPIC;
+      flush();
     });
 
     test('cherry pick topic submit', done => {
       element.branch = 'master';
-      const executeChangeActionStub = sinon.stub(element.$.restAPI,
+      const executeChangeActionStub = stubRestApi(
           'executeChangeAction').returns(Promise.resolve([]));
       MockInteractions.tap(element.shadowRoot.
           querySelector('gr-dialog').$.confirm);
@@ -129,6 +129,38 @@
       });
     });
 
+    test('deselecting a change removes it from being cherry picked', () => {
+      element.branch = 'master';
+      const executeChangeActionStub = stubRestApi(
+          'executeChangeAction').returns(Promise.resolve([]));
+      const checkboxes = element.shadowRoot.querySelectorAll(
+          'input[type="checkbox"]');
+      assert.equal(checkboxes.length, 2);
+      assert.isTrue(checkboxes[0].checked);
+      MockInteractions.tap(checkboxes[0]);
+      MockInteractions.tap(element.shadowRoot.
+          querySelector('gr-dialog').$.confirm);
+      flush();
+      assert.equal(executeChangeActionStub.callCount, 1);
+    });
+
+    test('deselecting all change shows error message', () => {
+      element.branch = 'master';
+      const executeChangeActionStub = stubRestApi(
+          'executeChangeAction').returns(Promise.resolve([]));
+      const checkboxes = element.shadowRoot.querySelectorAll(
+          'input[type="checkbox"]');
+      assert.equal(checkboxes.length, 2);
+      MockInteractions.tap(checkboxes[0]);
+      MockInteractions.tap(checkboxes[1]);
+      MockInteractions.tap(element.shadowRoot.
+          querySelector('gr-dialog').$.confirm);
+      flush();
+      assert.equal(executeChangeActionStub.callCount, 0);
+      assert.equal(element.shadowRoot.querySelector('.error-message').innerText
+          , 'No change selected');
+    });
+
     test('_computeStatusClass', () => {
       assert.equal(element._computeStatusClass({id: 1}, {1: {status: 'RUNNING'},
       }), '');
@@ -139,6 +171,9 @@
     test('submit button is blocked while cherry picks is running', done => {
       const confirmButton = element.shadowRoot.querySelector('gr-dialog').$
           .confirm;
+      assert.isTrue(confirmButton.hasAttribute('disabled'));
+      element.branch = 'b';
+      flush();
       assert.isFalse(confirmButton.hasAttribute('disabled'));
       element.updateStatus(changes[0], {status: 'RUNNING'});
       flush(() => {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
index 7150ddf..eb4053a 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
@@ -17,28 +17,18 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-move-dialog_html';
 import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {RepoName, BranchName} from '../../../types/common';
 import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {appContext} from '../../../services/app-context';
 
 const SUGGESTIONS_LIMIT = 15;
 
-export interface GrConfirmMoveDialog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-confirm-move-dialog')
-export class GrConfirmMoveDialog extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrConfirmMoveDialog extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -73,6 +63,8 @@
     };
   }
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = (text: string) => this._getProjectBranchesSuggestions(text);
@@ -107,7 +99,7 @@
     if (input.startsWith('refs/heads/')) {
       input = input.substring('refs/heads/'.length);
     }
-    return this.$.restAPI
+    return this.restApiService
       .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
       .then(response => {
         const branches: AutocompleteSuggestion[] = [];
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts
index b5b46d6..7c3c719 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts
@@ -56,9 +56,7 @@
       <p class="warning">
         Warning: moving a change will not change its parents.
       </p>
-      <label for="branchInput">
-        Move change to branch
-      </label>
+      <label for="branchInput"> Move change to branch </label>
       <gr-autocomplete
         id="branchInput"
         text="{{branch}}"
@@ -66,9 +64,7 @@
         placeholder="Destination branch"
       >
       </gr-autocomplete>
-      <label for="messageInput">
-        Move Change Message
-      </label>
+      <label for="messageInput"> Move Change Message </label>
       <iron-autogrow-textarea
         id="messageInput"
         class="message"
@@ -79,5 +75,4 @@
       ></iron-autogrow-textarea>
     </div>
   </gr-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js
index d51388e..36a2ad3 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-confirm-move-dialog.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-confirm-move-dialog');
 
@@ -24,20 +25,18 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getRepoBranches(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve([
-            {
-              ref: 'refs/heads/test-branch',
-              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-              can_delete: true,
-            },
-          ]);
-        } else {
-          return Promise.resolve(undefined);
-        }
-      },
+    stubRestApi('getRepoBranches').callsFake(input => {
+      if (input.startsWith('test')) {
+        return Promise.resolve([
+          {
+            ref: 'refs/heads/test-branch',
+            revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+            can_delete: true,
+          },
+        ]);
+      } else {
+        return Promise.resolve(undefined);
+      }
     });
     element = basicFixture.instantiate();
     element.project = 'test-project';
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index db0e1ff..4e6c963 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -16,21 +16,17 @@
  */
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-rebase-dialog_html';
 import {customElement, property, observe} from '@polymer/decorators';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {NumericChangeId, BranchName} from '../../../types/common';
 import {
   GrAutocomplete,
   AutocompleteQuery,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
 interface RebaseChange {
   name: string;
@@ -43,7 +39,6 @@
 
 export interface GrConfirmRebaseDialog {
   $: {
-    restAPI: RestApiService & Element;
     parentInput: GrAutocomplete;
     rebaseOnParentInput: HTMLInputElement;
     rebaseOnOtherInput: HTMLInputElement;
@@ -52,9 +47,7 @@
 }
 
 @customElement('gr-confirm-rebase-dialog')
-export class GrConfirmRebaseDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrConfirmRebaseDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -92,6 +85,8 @@
   @property({type: Array})
   _recentChanges?: RebaseChange[];
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = input => this._getChangeSuggestions(input);
@@ -104,18 +99,15 @@
   // in case there are new/updated changes in the generic query since the
   // last time it was run.
   fetchRecentChanges() {
-    return this.$.restAPI
+    return this.restApiService
       .getChanges(undefined, 'is:open -age:90d')
       .then(response => {
         if (!response) return [];
         const changes: RebaseChange[] = [];
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
-          }
+        for (const change of response) {
           changes.push({
-            name: `${response[key]._number}: ${response[key].subject}`,
-            value: response[key]._number,
+            name: `${change._number}: ${change.subject}`,
+            value: change._number,
           });
         }
         this._recentChanges = changes;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
index 687d31f..6def4a5 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
@@ -108,9 +108,7 @@
         />
         <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
           Rebase on a specific change, ref, or commit
-          <span hidden$="[[!hasParent]]">
-            (breaks relation chain)
-          </span>
+          <span hidden$="[[!hasParent]]"> (breaks relation chain) </span>
         </label>
       </div>
       <div class="parentRevisionContainer">
@@ -127,5 +125,4 @@
       </div>
     </div>
   </gr-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js
index 8bce572..0faf604 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-confirm-rebase-dialog.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-confirm-rebase-dialog');
 
@@ -102,6 +103,7 @@
 
   suite('parent suggestions', () => {
     let recentChanges;
+    let getChangesStub;
     setup(() => {
       recentChanges = [
         {
@@ -118,7 +120,7 @@
         },
       ];
 
-      sinon.stub(element.$.restAPI, 'getChanges').returns(Promise.resolve(
+      getChangesStub = stubRestApi('getChanges').returns(Promise.resolve(
           [
             {
               _number: 123,
@@ -141,29 +143,26 @@
       return element._getRecentChanges()
           .then(() => {
             assert.deepEqual(element._recentChanges, recentChanges);
-            assert.equal(element.$.restAPI.getChanges.callCount, 1);
+            assert.equal(getChangesStub.callCount, 1);
             // When called a second time, should not re-request recent changes.
             element._getRecentChanges();
           })
           .then(() => {
             assert.equal(element._getRecentChanges.callCount, 2);
-            assert.equal(element.$.restAPI.getChanges.callCount, 1);
+            assert.equal(getChangesStub.callCount, 1);
           });
     });
 
     test('_filterChanges', () => {
       assert.equal(element._filterChanges('123', recentChanges).length, 1);
       assert.equal(element._filterChanges('12', recentChanges).length, 2);
-      assert.equal(element._filterChanges('awesome', recentChanges).length,
-          3);
-      assert.equal(element._filterChanges('third', recentChanges).length,
-          1);
+      assert.equal(element._filterChanges('awesome', recentChanges).length, 3);
+      assert.equal(element._filterChanges('third', recentChanges).length, 1);
 
       element.changeNumber = 123;
       assert.equal(element._filterChanges('123', recentChanges).length, 0);
       assert.equal(element._filterChanges('124', recentChanges).length, 1);
-      assert.equal(element._filterChanges('awesome', recentChanges).length,
-          2);
+      assert.equal(element._filterChanges('awesome', recentChanges).length, 2);
     });
 
     test('input text change triggers function', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index 5c0b19f..450551b 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -17,14 +17,12 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-revert-dialog_html';
 import {customElement, property} from '@polymer/decorators';
-import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
 import {ChangeInfo, CommitId} from '../../../types/common';
+import {fireAlert} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -40,15 +38,8 @@
   message?: string;
 }
 
-export interface GrConfirmRevertDialog {
-  $: {
-    jsAPI: JsApiService & Element;
-  };
-}
 @customElement('gr-confirm-revert-dialog')
-export class GrConfirmRevertDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrConfirmRevertDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -92,6 +83,8 @@
   @property({type: Array})
   _revertMessages: string[] = [];
 
+  private readonly jsAPI = appContext.jsApiService;
+
   _computeIfSingleRevert(revertType: number) {
     return revertType === RevertType.REVERT_SINGLE_CHANGE;
   }
@@ -101,7 +94,7 @@
   }
 
   _modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
-    return this.$.jsAPI.modifyRevertMsg(change, message, commitMessage);
+    return this.jsAPI.modifyRevertMsg(change, message, commitMessage);
   }
 
   populate(change: ChangeInfo, commitMessage: string, changes: ChangeInfo[]) {
@@ -124,13 +117,7 @@
     const originalTitle = (commitMessage || '').split('\n')[0];
     const revertTitle = `Revert "${originalTitle}"`;
     if (!commitHash) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_COMMIT_NOT_FOUND},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
     }
     const revertCommitText = `This reverts commit ${commitHash}.`;
@@ -157,7 +144,7 @@
     msg: string,
     commitMessage: string
   ) {
-    return this.$.jsAPI.modifyRevertSubmissionMsg(change, msg, commitMessage);
+    return this.jsAPI.modifyRevertSubmissionMsg(change, msg, commitMessage);
   }
 
   _populateRevertSubmissionMessage(
@@ -168,13 +155,7 @@
     // Follow the same convention of the revert
     const commitHash = change.current_revision;
     if (!commitHash) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_COMMIT_NOT_FOUND},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
     }
     if (!changes || changes.length <= 1) return;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
index f5561fc..17647ad 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
@@ -53,9 +53,7 @@
     on-confirm="_handleConfirmTap"
     on-cancel="_handleCancelTap"
   >
-    <div class="header" slot="header">
-      Revert Merged Change
-    </div>
+    <div class="header" slot="header">Revert Merged Change</div>
     <div class="main" slot="main">
       <div class="error" hidden$="[[!_showErrorMessage]]">
         <span> A reason is required </span>
@@ -87,9 +85,7 @@
         </div></template
       >
       <gr-endpoint-decorator name="confirm-revert-change">
-        <label for="messageInput">
-          Revert Commit Message
-        </label>
+        <label for="messageInput"> Revert Commit Message </label>
         <iron-autogrow-textarea
           id="messageInput"
           class="message"
@@ -100,5 +96,4 @@
       </gr-endpoint-decorator>
     </div>
   </gr-dialog>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
index 9754e89..bd268fd 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
@@ -16,27 +16,18 @@
  */
 import '../../shared/gr-dialog/gr-dialog';
 import '../../../styles/shared-styles';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-revert-submission-dialog_html';
 import {customElement, property} from '@polymer/decorators';
-import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
 import {ChangeInfo} from '../../../types/common';
+import {fireAlert} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
 const CHANGE_SUBJECT_LIMIT = 50;
 
-export interface GrConfirmRevertSubmissionDialog {
-  $: {
-    jsAPI: JsApiService & Element;
-  };
-}
 @customElement('gr-confirm-revert-submission-dialog')
-export class GrConfirmRevertSubmissionDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrConfirmRevertSubmissionDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -59,6 +50,8 @@
   @property({type: String})
   commitMessage?: string;
 
+  private readonly jsAPI = appContext.jsApiService;
+
   _getTrimmedChangeSubject(subject: string) {
     if (!subject) return '';
     if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
@@ -69,7 +62,7 @@
     if (!change || !this.message || !this.commitMessage) {
       return this.message;
     }
-    return this.$.jsAPI.modifyRevertSubmissionMsg(
+    return this.jsAPI.modifyRevertSubmissionMsg(
       change,
       this.message,
       this.commitMessage
@@ -86,13 +79,7 @@
     // Follow the same convention of the revert
     const commitHash = change.current_revision;
     if (!commitHash) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_COMMIT_NOT_FOUND},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
     }
     const revertTitle = `Revert submission ${change.submission_id}`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts
index cae4e1f..48bd796 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts
@@ -45,9 +45,7 @@
   >
     <div class="header" slot="header">Revert Submission</div>
     <div class="main" slot="main">
-      <label for="messageInput">
-        Revert Commit Message
-      </label>
+      <label for="messageInput"> Revert Commit Message </label>
       <iron-autogrow-textarea
         id="messageInput"
         class="message"
@@ -57,5 +55,4 @@
       ></iron-autogrow-textarea>
     </div>
   </gr-dialog>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index 666f95d..33f7304 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -17,17 +17,17 @@
 import '@polymer/iron-icon/iron-icon';
 import '../../shared/gr-icons/gr-icons';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+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';
 
 export interface GrConfirmSubmitDialog {
   $: {
@@ -35,9 +35,7 @@
   };
 }
 @customElement('gr-confirm-submit-dialog')
-export class GrConfirmSubmitDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrConfirmSubmitDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -60,6 +58,16 @@
   @property({type: Object})
   action?: ActionInfo;
 
+  @property({type: Array})
+  commentThreads?: CommentThread[] = [];
+
+  @property({type: Boolean})
+  _initialised = false;
+
+  init() {
+    this._initialised = true;
+  }
+
   resetFocus() {
     this.$.dialog.resetFocus();
   }
@@ -72,10 +80,15 @@
     );
   }
 
+  _computeUnresolvedThreads(commentThreads?: CommentThread[]) {
+    if (!commentThreads) return [];
+    return commentThreads.filter(thread => isUnresolved(thread));
+  }
+
   _computeUnresolvedCommentsWarning(change: ChangeInfo) {
     const unresolvedCount = change.unresolved_comment_count;
-    const plural = unresolvedCount && unresolvedCount > 1 ? 's' : '';
-    return `Heads Up! ${unresolvedCount} unresolved comment${plural}.`;
+    if (!unresolvedCount) throw new Error('unresolved comments undefined or 0');
+    return `Heads Up! ${pluralize(unresolvedCount, 'unresolved comment')}.`;
   }
 
   _handleConfirmTap(e: MouseEvent) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
index 84668ed..e4662da 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
@@ -25,7 +25,7 @@
       margin-bottom: var(--spacing-l);
     }
     .warningBeforeSubmit {
-      color: var(--error-text-color);
+      color: var(--warning-foreground);
       vertical-align: top;
       margin-right: var(--spacing-s);
     }
@@ -43,43 +43,57 @@
     on-cancel="_handleCancelTap"
     on-confirm="_handleConfirmTap"
   >
-    <div class="header" slot="header">
-      [[action.label]]
-    </div>
-    <div class="main" slot="main">
-      <gr-endpoint-decorator name="confirm-submit-change">
-        <p>Ready to submit “<strong>[[change.subject]]</strong>”?</p>
-        <template is="dom-if" if="[[change.is_private]]">
-          <p>
+    <template is="dom-if" if="[[_initialised]]">
+      <div class="header" slot="header">[[action.label]]</div>
+      <div class="main" slot="main">
+        <gr-endpoint-decorator name="confirm-submit-change">
+          <p>Ready to submit “<strong>[[change.subject]]</strong>”?</p>
+          <template is="dom-if" if="[[change.is_private]]">
+            <p>
+              <iron-icon
+                icon="gr-icons:warning"
+                class="warningBeforeSubmit"
+              ></iron-icon>
+              <strong>Heads Up!</strong>
+              Submitting this private change will also make it public.
+            </p>
+          </template>
+          <template is="dom-if" if="[[change.unresolved_comment_count]]">
+            <p>
+              <iron-icon
+                icon="gr-icons:warning"
+                class="warningBeforeSubmit"
+              ></iron-icon>
+              [[_computeUnresolvedCommentsWarning(change)]]
+            </p>
+            <gr-thread-list
+              id="commentList"
+              threads="[[_computeUnresolvedThreads(commentThreads)]]"
+              change="[[change]]"
+              change-num="[[change._number]]"
+              logged-in="true"
+              hide-toggle-buttons
+            >
+            </gr-thread-list>
+          </template>
+          <template is="dom-if" if="[[_computeHasChangeEdit(change)]]">
             <iron-icon
-              icon="gr-icons:error"
+              icon="gr-icons:warning"
               class="warningBeforeSubmit"
             ></iron-icon>
-            <strong>Heads Up!</strong>
-            Submitting this private change will also make it public.
-          </p>
-        </template>
-        <template is="dom-if" if="[[change.unresolved_comment_count]]">
-          <p>
-            <iron-icon
-              icon="gr-icons:error"
-              class="warningBeforeSubmit"
-            ></iron-icon>
-            [[_computeUnresolvedCommentsWarning(change)]]
-          </p>
-        </template>
-        <template is="dom-if" if="[[_computeHasChangeEdit(change)]]">
-          <iron-icon
-            icon="gr-icons:error"
-            class="warningBeforeSubmit"
-          ></iron-icon>
-          Your unpublished edit will not be submitted. Did you forget to click
-          <b>PUBLISH</b>?
-        </template>
-        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-        <gr-endpoint-param name="action" value="[[action]]"></gr-endpoint-param>
-      </gr-endpoint-decorator>
-    </div>
+            Your unpublished edit will not be submitted. Did you forget to click
+            <b>PUBLISH</b>?
+          </template>
+          <gr-endpoint-param
+            name="change"
+            value="[[change]]"
+          ></gr-endpoint-param>
+          <gr-endpoint-param
+            name="action"
+            value="[[action]]"
+          ></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>
+    </template>
   </gr-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
deleted file mode 100644
index e16ffdb..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
+++ /dev/null
@@ -1,79 +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-submit-dialog.js';
-
-const basicFixture = fixtureFromElement('gr-confirm-submit-dialog');
-
-suite('gr-file-list-header tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('display', () => {
-    element.action = {label: 'my-label'};
-    element.change = {
-      subject: 'my-subject',
-      revisions: {},
-    };
-    flush();
-    const header = element.shadowRoot
-        .querySelector('.header');
-    assert.equal(header.textContent.trim(), 'my-label');
-
-    const message = element.shadowRoot
-        .querySelector('.main p');
-    assert.notEqual(message.textContent.length, 0);
-    assert.notEqual(message.textContent.indexOf('my-subject'), -1);
-  });
-
-  test('_computeUnresolvedCommentsWarning', () => {
-    const change = {unresolved_comment_count: 1};
-    assert.equal(element._computeUnresolvedCommentsWarning(change),
-        'Heads Up! 1 unresolved comment.');
-
-    const change2 = {unresolved_comment_count: 2};
-    assert.equal(element._computeUnresolvedCommentsWarning(change2),
-        'Heads Up! 2 unresolved comments.');
-  });
-
-  test('_computeHasChangeEdit', () => {
-    const change = {
-      revisions: {
-        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
-          _number: 'edit',
-        },
-      },
-      unresolved_comment_count: 0,
-    };
-
-    assert.equal(element._computeHasChangeEdit(change), true);
-
-    const change2 = {
-      revisions: {
-        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
-          _number: 2,
-        },
-      },
-    };
-    assert.equal(element._computeHasChangeEdit(change2), false);
-  });
-});
-
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
new file mode 100644
index 0000000..e9f3019
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
@@ -0,0 +1,89 @@
+/**
+ * @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 {createChange, createRevision} from '../../../test/test-data-generators';
+import {queryAndAssert} from '../../../test/test-utils';
+import {PatchSetNum} from '../../../types/common';
+import {GrConfirmSubmitDialog} from './gr-confirm-submit-dialog';
+
+const basicFixture = fixtureFromElement('gr-confirm-submit-dialog');
+
+suite('gr-confirm-submit-dialog tests', () => {
+  let element: GrConfirmSubmitDialog;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element._initialised = true;
+  });
+
+  test('display', () => {
+    element.action = {label: 'my-label'};
+    element.change = {
+      ...createChange(),
+      subject: 'my-subject',
+      revisions: {},
+    };
+    flush();
+    const header = queryAndAssert(element, '.header');
+    assert.equal(header.textContent!.trim(), 'my-label');
+
+    const message = queryAndAssert(element, '.main p');
+    assert.isNotEmpty(message.textContent);
+    assert.notEqual(message.textContent!.indexOf('my-subject'), -1);
+  });
+
+  test('_computeUnresolvedCommentsWarning', () => {
+    const change = {...createChange(), unresolved_comment_count: 1};
+    assert.equal(
+      element._computeUnresolvedCommentsWarning(change),
+      'Heads Up! 1 unresolved comment.'
+    );
+
+    const change2 = {...createChange(), unresolved_comment_count: 2};
+    assert.equal(
+      element._computeUnresolvedCommentsWarning(change2),
+      'Heads Up! 2 unresolved comments.'
+    );
+  });
+
+  test('_computeHasChangeEdit', () => {
+    const change = {
+      ...createChange(),
+      revisions: {
+        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
+          ...createRevision(),
+          _number: 'edit' as PatchSetNum,
+        },
+      },
+      unresolved_comment_count: 0,
+    };
+
+    assert.isTrue(element._computeHasChangeEdit(change));
+
+    const change2 = {
+      ...createChange(),
+      revisions: {
+        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
+          ...createRevision(),
+          _number: 2 as PatchSetNum,
+        },
+      },
+    };
+    assert.isFalse(element._computeHasChangeEdit(change2));
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index 27d9756..fe1b618 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
@@ -16,12 +16,9 @@
  */
 import '../../../styles/shared-styles';
 import '../../shared/gr-download-commands/gr-download-commands';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-download-dialog_html';
-import {patchNumEquals} from '../../../utils/patch-set-util';
-import {changeBaseURL} from '../../../utils/change-util';
+import {changeBaseURL, getRevisionKey} from '../../../utils/change-util';
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {ChangeInfo, ServerInfo, PatchSetNum} from '../../../types/common';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
@@ -39,9 +36,7 @@
 }
 
 @customElement('gr-download-dialog')
-export class GrDownloadDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDownloadDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -72,7 +67,7 @@
     }
 
     for (const rev of Object.values(this.change.revisions || {})) {
-      if (patchNumEquals(rev._number, this.patchNum)) {
+      if (rev._number === this.patchNum) {
         const fetch = rev.fetch;
         if (fetch) {
           return Object.keys(fetch).sort();
@@ -113,7 +108,7 @@
     if (!change || !selectedScheme) return [];
     for (const rev of Object.values(change.revisions || {})) {
       if (
-        patchNumEquals(rev._number, patchNum) &&
+        rev._number === patchNum &&
         rev &&
         rev.fetch &&
         hasOwnProperty(rev.fetch, selectedScheme)
@@ -123,14 +118,8 @@
       }
     }
     const commands = [];
-    for (const title in commandObj) {
-      if (!commandObj || !hasOwnProperty(commandObj, title)) {
-        continue;
-      }
-      commands.push({
-        title,
-        command: commandObj[title],
-      });
+    for (const [title, command] of Object.entries(commandObj ?? {})) {
+      commands.push({title, command});
     }
     return commands;
   }
@@ -169,13 +158,9 @@
       return '';
     }
 
-    let shortRev = '';
-    for (const rev in change.revisions) {
-      if (patchNumEquals(change.revisions[rev]._number, patchNum)) {
-        shortRev = rev.substr(0, 7);
-        break;
-      }
-    }
+    const rev = getRevisionKey(change, patchNum) ?? '';
+    const shortRev = rev.substr(0, 7);
+
     return shortRev + '.diff.' + (zip ? 'zip' : 'base64');
   }
 
@@ -185,7 +170,7 @@
       return false;
     }
     for (const rev of Object.values(change.revisions || {})) {
-      if (patchNumEquals(rev._number, patchNum)) {
+      if (rev._number === patchNum) {
         const parentLength =
           rev.commit && rev.commit.parents ? rev.commit.parents.length : 0;
         return parentLength === 0 || parentLength > 1;
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
similarity index 65%
rename from polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
rename to polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
index 7401026..52ec8af 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -15,29 +15,46 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-download-dialog.js';
+import '../../../test/common-test-setup-karma';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {
+  createChange,
+  createCommit,
+  createRevision,
+  createRevisions,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {
+  CommitId,
+  NumericChangeId,
+  PatchSetNum,
+  RepoName,
+} from '../../../types/common';
+import {GrDownloadDialog} from './gr-download-dialog';
 
 const basicFixture = fixtureFromElement('gr-download-dialog');
 
 function getChangeObject() {
   return {
-    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
+    ...createChange(),
+    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72' as CommitId,
     revisions: {
       '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
-        _number: 1,
-        commit: {
-          parents: [],
-        },
+        ...createRevision(),
+        commit: createCommit(),
         fetch: {
           repo: {
+            url: 'my.url',
+            ref: 'refs/changes/5/6/1',
             commands: {
               repo: 'repo download test-project 5/1',
             },
           },
           ssh: {
+            url: 'my.url',
+            ref: 'refs/changes/5/6/1',
             commands: {
-              'Checkout':
+              Checkout:
                 'git fetch ' +
                 'ssh://andybons@localhost:29418/test-project ' +
                 'refs/changes/05/5/1 && git checkout FETCH_HEAD',
@@ -50,15 +67,17 @@
                 'ssh://andybons@localhost:29418/test-project ' +
                 'refs/changes/05/5/1 ' +
                 '&& git format-patch -1 --stdout FETCH_HEAD',
-              'Pull':
+              Pull:
                 'git pull ' +
                 'ssh://andybons@localhost:29418/test-project ' +
                 'refs/changes/05/5/1',
             },
           },
           http: {
+            url: 'my.url',
+            ref: 'refs/changes/5/6/1',
             commands: {
-              'Checkout':
+              Checkout:
                 'git fetch ' +
                 'http://andybons@localhost:8080/a/test-project ' +
                 'refs/changes/05/5/1 && git checkout FETCH_HEAD',
@@ -71,7 +90,7 @@
                 'http://andybons@localhost:8080/a/test-project ' +
                 'refs/changes/05/5/1 && ' +
                 'git format-patch -1 --stdout FETCH_HEAD',
-              'Pull':
+              Pull:
                 'git pull ' +
                 'http://andybons@localhost:8080/a/test-project ' +
                 'refs/changes/05/5/1',
@@ -85,41 +104,24 @@
 
 function getChangeObjectNoFetch() {
   return {
-    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
-    revisions: {
-      '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
-        _number: 1,
-        commit: {
-          parents: [],
-        },
-        fetch: {},
-      },
-    },
+    ...createChange(),
+    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72' as CommitId,
+    revisions: createRevisions(1),
   };
 }
 
 suite('gr-download-dialog', () => {
-  let element;
+  let element: GrDownloadDialog;
 
   setup(() => {
     element = basicFixture.instantiate();
-    element.patchNum = '1';
-    element.config = {
-      schemes: {
-        'anonymous http': {},
-        'http': {},
-        'repo': {},
-        'ssh': {},
-      },
-      archives: ['tgz', 'tar', 'tbz2', 'txz'],
-    };
-
+    element.patchNum = 1 as PatchSetNum;
+    element.config = createServerInfo();
     flush();
   });
 
   test('anchors use download attribute', () => {
-    const anchors = Array.from(
-        element.root.querySelectorAll('a'));
+    const anchors = Array.from(element.root!.querySelectorAll('a'));
     assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
   });
 
@@ -152,17 +154,28 @@
     });
 
     test('computed fields', () => {
-      assert.equal(element._computeArchiveDownloadLink(
-          {project: 'test/project', _number: 123}, 2, 'tgz'),
-      '/changes/test%2Fproject~123/revisions/2/archive?format=tgz');
+      assert.equal(
+        element._computeArchiveDownloadLink(
+          {
+            ...createChange(),
+            project: 'test/project' as RepoName,
+            _number: 123 as NumericChangeId,
+          },
+          2 as PatchSetNum,
+          'tgz'
+        ),
+        '/changes/test%2Fproject~123/revisions/2/archive?format=tgz'
+      );
     });
 
     test('close event', done => {
       element.addEventListener('close', () => {
         done();
       });
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.closeButtonContainer gr-button'));
+      const closeButton = element.shadowRoot!.querySelector(
+        '.closeButtonContainer gr-button'
+      );
+      tap(closeButton!);
     });
   });
 
@@ -172,35 +185,49 @@
   });
 
   test('_computeHidePatchFile', () => {
-    const patchNum = '1';
+    const patchNum = 1 as PatchSetNum;
 
     const changeWithNoParent = {
+      ...createChange(),
       revisions: {
-        r1: {_number: 1, commit: {parents: []}},
+        r1: {...createRevision(), commit: createCommit()},
       },
     };
     assert.isTrue(element._computeHidePatchFile(changeWithNoParent, patchNum));
 
     const changeWithOneParent = {
+      ...createChange(),
       revisions: {
-        r1: {_number: 1, commit: {parents: [
-          {commit: 'p1'},
-        ]}},
+        r1: {
+          ...createRevision(),
+          commit: {
+            ...createCommit(),
+            parents: [{commit: 'p1' as CommitId, subject: 'subject1'}],
+          },
+        },
       },
     };
     assert.isFalse(
-        element._computeHidePatchFile(changeWithOneParent, patchNum));
+      element._computeHidePatchFile(changeWithOneParent, patchNum)
+    );
 
     const changeWithMultipleParents = {
+      ...createChange(),
       revisions: {
-        r1: {_number: 1, commit: {parents: [
-          {commit: 'p1'},
-          {commit: 'p2'},
-        ]}},
+        r1: {
+          ...createRevision(),
+          commit: {
+            ...createCommit(),
+            parents: [
+              {commit: 'p1' as CommitId, subject: 'subject1'},
+              {commit: 'p2' as CommitId, subject: 'subject2'},
+            ],
+          },
+        },
       },
     };
     assert.isTrue(
-        element._computeHidePatchFile(changeWithMultipleParents, patchNum));
+      element._computeHidePatchFile(changeWithMultipleParents, patchNum)
+    );
   });
 });
-
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index b86dd90..e94f67e 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
@@ -18,46 +18,34 @@
 import '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import '../../diff/gr-patch-range-select/gr-patch-range-select';
 import '../../edit/gr-edit-controls/gr-edit-controls';
-import '../../shared/gr-editable-label/gr-editable-label';
-import '../../shared/gr-linked-chip/gr-linked-chip';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icons/gr-icons';
 import '../gr-commit-info/gr-commit-info';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-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,
-  getRevisionByPatchNum,
-  patchNumEquals,
-  PatchSet,
-} from '../../../utils/patch-set-util';
-import {property, computed, observe, customElement} from '@polymer/decorators';
+import {computeLatestPatchNum, PatchSet} from '../../../utils/patch-set-util';
+import {property, customElement} from '@polymer/decorators';
 import {
   AccountInfo,
   ChangeInfo,
   PatchSetNum,
   CommitInfo,
   ServerInfo,
-  DiffPreferencesInfo,
   RevisionInfo,
   NumericChangeId,
+  BasePatchSetNum,
 } from '../../../types/common';
+import {DiffPreferencesInfo} from '../../../types/diff';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {GrDiffModeSelector} from '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
-import {DiffViewMode} from '../../../constants/constants';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {ChangeStatus, DiffViewMode} from '../../../constants/constants';
 import {GrButton} from '../../shared/gr-button/gr-button';
+import {fireEvent} from '../../../utils/event-util';
 
-// Maximum length for patch set descriptions.
-const PATCH_DESC_MAX_LENGTH = 500;
 const MERGED_STATUS = 'MERGED';
 
 declare global {
@@ -69,16 +57,13 @@
 export interface GrFileListHeader {
   $: {
     modeSelect: GrDiffModeSelector;
-    restAPI: RestApiService & Element;
     expandBtn: GrButton;
     collapseBtn: GrButton;
   };
 }
 
 @customElement('gr-file-list-header')
-export class GrFileListHeader extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrFileListHeader extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -103,10 +88,6 @@
    * @event open-download-dialog
    */
 
-  /**
-   * @event open-upload-help-dialog
-   */
-
   @property({type: Object})
   account: AccountInfo | undefined;
 
@@ -153,7 +134,7 @@
   patchNum?: PatchSetNum;
 
   @property({type: String})
-  basePatchNum?: PatchSetNum;
+  basePatchNum?: BasePatchSetNum;
 
   @property({type: String})
   filesExpanded?: FilesExpandedState;
@@ -163,156 +144,33 @@
   @property({type: Number})
   readonly _maxFilesForBulkActions = 225;
 
-  @property({type: String})
-  _patchsetDescription = '';
-
   @property({type: Object})
   revisionInfo?: RevisionInfo;
 
-  @computed('loggedIn', 'change', 'account')
-  get _descriptionReadOnly(): boolean {
-    if (
-      this.loggedIn === undefined ||
-      this.change === undefined ||
-      this.account === undefined
-    ) {
-      return true;
-    }
-
-    return !(
-      this.loggedIn &&
-      this.account._account_id === this.change.owner._account_id
-    );
-  }
-
   setDiffViewMode(mode: DiffViewMode) {
     this.$.modeSelect.setMode(mode);
   }
 
   _expandAllDiffs() {
-    this.dispatchEvent(
-      new CustomEvent('expand-diffs', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'expand-diffs');
   }
 
   _collapseAllDiffs() {
-    this.dispatchEvent(
-      new CustomEvent('collapse-diffs', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'collapse-diffs');
   }
 
   _computeExpandedClass(filesExpanded: FilesExpandedState) {
     const classes = [];
     if (filesExpanded === FilesExpandedState.ALL) {
-      classes.push('expanded');
-    }
-    if (
-      filesExpanded === FilesExpandedState.SOME ||
-      filesExpanded === FilesExpandedState.ALL
-    ) {
       classes.push('openFile');
+      classes.push('allExpanded');
+    } else if (filesExpanded === FilesExpandedState.SOME) {
+      classes.push('openFile');
+      classes.push('someExpanded');
     }
     return classes.join(' ');
   }
 
-  _computeDescriptionPlaceholder(readOnly: boolean) {
-    return (readOnly ? 'No' : 'Add') + ' patchset description';
-  }
-
-  @observe('change', 'patchNum')
-  _computePatchSetDescription(change: ChangeInfo, patchNum: PatchSetNum) {
-    // Polymer 2: check for undefined
-    if (
-      change === undefined ||
-      change.revisions === undefined ||
-      patchNum === undefined
-    ) {
-      return;
-    }
-
-    const rev = getRevisionByPatchNum(
-      Object.values(change.revisions),
-      patchNum
-    );
-    this._patchsetDescription = rev?.description
-      ? rev.description.substring(0, PATCH_DESC_MAX_LENGTH)
-      : '';
-  }
-
-  _handleDescriptionRemoved(e: CustomEvent) {
-    return this._updateDescription('', e);
-  }
-
-  /**
-   * @param revisions The revisions object keyed by revision hashes
-   * @param patchSet A revision already fetched from {revisions}
-   * @return the SHA hash corresponding to the revision.
-   */
-  _getPatchsetHash(
-    revisions: {[revisionId: string]: RevisionInfo},
-    patchSet: RevisionInfo
-  ) {
-    for (const sha of Object.keys(revisions)) {
-      if (revisions[sha] === patchSet) {
-        return sha;
-      }
-    }
-    throw new Error('patchset hash not found');
-  }
-
-  _handleDescriptionChanged(e: CustomEvent) {
-    const desc = e.detail.trim();
-    this._updateDescription(desc, e);
-  }
-
-  /**
-   * Update the patchset description with the rest API.
-   */
-  _updateDescription(desc: string, e: CustomEvent) {
-    if (
-      !this.change ||
-      !this.change.revisions ||
-      !this.patchNum ||
-      !this.changeNum
-    )
-      return;
-    // target can be either gr-editable-label or gr-linked-chip
-    const target = (dom(e) as EventApi).rootTarget as HTMLElement & {
-      disabled: boolean;
-    };
-    if (target) {
-      target.disabled = true;
-    }
-    const rev = getRevisionByPatchNum(
-      Object.values(this.change.revisions),
-      this.patchNum
-    )!;
-    const sha = this._getPatchsetHash(this.change.revisions, rev);
-    return this.$.restAPI
-      .setDescription(this.changeNum, this.patchNum, desc)
-      .then((res: Response) => {
-        if (res.ok) {
-          if (target) {
-            target.disabled = false;
-          }
-          this.set(['change', 'revisions', sha, 'description'], desc);
-          this._patchsetDescription = desc;
-        }
-      })
-      .catch(() => {
-        if (target) {
-          target.disabled = false;
-        }
-        return;
-      });
-  }
-
   _computePrefsButtonHidden(
     prefs: DiffPreferencesInfo,
     diffPrefsDisabled: boolean
@@ -327,11 +185,17 @@
     return shownFileCount <= maxFilesForBulkActions;
   }
 
+  _showAddPatchsetDescription(
+    patchsetDescription: string,
+    change?: ChangeInfo
+  ) {
+    return !patchsetDescription && change?.status === ChangeStatus.NEW;
+  }
+
   _handlePatchChange(e: CustomEvent) {
     const {basePatchNum, patchNum} = e.detail;
     if (
-      (patchNumEquals(basePatchNum, this.basePatchNum) &&
-        patchNumEquals(patchNum, this.patchNum)) ||
+      (basePatchNum === this.basePatchNum && patchNum === this.patchNum) ||
       !this.change
     ) {
       return;
@@ -341,22 +205,12 @@
 
   _handlePrefsTap(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('open-diff-prefs', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'open-diff-prefs');
   }
 
   _handleIncludedInTap(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('open-included-in-dialog', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'open-included-in-dialog');
   }
 
   _handleDownloadTap(e: Event) {
@@ -373,7 +227,7 @@
 
   _computePatchInfoClass(patchNum?: PatchSetNum, allPatchSets?: PatchSet[]) {
     const latestNum = computeLatestPatchNum(allPatchSets);
-    if (patchNumEquals(patchNum, latestNum)) {
+    if (patchNum === latestNum) {
       return '';
     }
     return 'patchInfoOldPatchSet';
@@ -382,21 +236,4 @@
   _hideIncludedIn(change?: ChangeInfo) {
     return change?.status === MERGED_STATUS ? '' : 'hide';
   }
-
-  _handleUploadTap(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('open-upload-help-dialog', {bubbles: false})
-    );
-  }
-
-  _computeUploadHelpContainerClass(change: ChangeInfo, account: AccountInfo) {
-    const changeIsMerged = change?.status === MERGED_STATUS;
-    const ownerId = change?.owner?._account_id || null;
-    const userId = account && account._account_id;
-    const userIsOwner = ownerId && userId && ownerId === userId;
-    const hideContainer = !userIsOwner || changeIsMerged;
-    return 'uploadContainer desktop' + (hideContainer ? ' hide' : '');
-  }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
index 1355412..8ebb029 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
@@ -29,7 +29,6 @@
     }
     .patchInfo-header {
       align-items: center;
-      border-top: 1px solid var(--border-color);
       display: flex;
       padding: var(--spacing-s) var(--spacing-l);
     }
@@ -48,12 +47,12 @@
     .patchInfoOldPatchSet .container.latestPatchContainer {
       display: initial;
     }
+    .editMode.patchInfoOldPatchSet .container.latestPatchContainer {
+      display: none;
+    }
     .latestPatchContainer a {
       text-decoration: none;
     }
-    gr-editable-label.descriptionLabel {
-      max-width: 100%;
-    }
     .mobile {
       display: none;
     }
@@ -80,17 +79,15 @@
       justify-content: flex-end;
     }
     #collapseBtn,
-    .expanded #expandBtn,
+    .allExpanded #expandBtn,
     .fileViewActions {
       display: none;
     }
-    .expanded #expandBtn {
-      display: none;
+    .someExpanded #expandBtn {
+      margin-right: 8px;
     }
-    gr-linked-chip {
-      --linked-chip-text-color: var(--primary-text-color);
-    }
-    .expanded #collapseBtn,
+    .someExpanded #collapseBtn,
+    .allExpanded #collapseBtn,
     .openFile .fileViewActions {
       align-items: center;
       display: flex;
@@ -162,29 +159,6 @@
           <span class="separator"></span>
           <a href$="[[changeUrl]]">Go to latest patch set</a>
         </span>
-        <span class="container descriptionContainer hideOnEdit">
-          <span class="separator"></span>
-          <template is="dom-if" if="[[_patchsetDescription]]">
-            <gr-linked-chip
-              id="descriptionChip"
-              text="[[_patchsetDescription]]"
-              removable="[[!_descriptionReadOnly]]"
-              on-remove="_handleDescriptionRemoved"
-            ></gr-linked-chip>
-          </template>
-          <template is="dom-if" if="[[!_patchsetDescription]]">
-            <gr-editable-label
-              id="descriptionLabel"
-              uppercase=""
-              class="descriptionLabel"
-              label-text="Add patchset description"
-              value="[[_patchsetDescription]]"
-              placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
-              read-only="[[_descriptionReadOnly]]"
-              on-changed="_handleDescriptionChanged"
-            ></gr-editable-label>
-          </template>
-        </span>
       </div>
     </div>
     <div class$="rightControls [[_computeExpandedClass(filesExpanded)]]">
@@ -198,11 +172,6 @@
           <span class="separator"></span>
         </span>
       </template>
-      <span class$="[[_computeUploadHelpContainerClass(change, account)]]">
-        <gr-button link="" class="upload" on-click="_handleUploadTap"
-          >Update Change</gr-button
-        >
-      </span>
       <span class="downloadContainer desktop">
         <gr-button
           link=""
@@ -273,5 +242,4 @@
       </div>
     </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
index 3469b3a..dd70678 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
@@ -21,6 +21,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import 'lodash/lodash.js';
 import {createRevisions} from '../../../test/test-data-generators.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-file-list-header');
 
@@ -28,11 +29,8 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({test: 'config'}); },
-      getAccount() { return Promise.resolve(null); },
-      _fetchSharedCacheURL() { return Promise.resolve({}); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({test: 'config'}));
+    stubRestApi('getAccount').returns(Promise.resolve(null));
     element = basicFixture.instantiate();
   });
 
@@ -61,99 +59,6 @@
     assert.isFalse(element.$.diffPrefsContainer.hidden);
   });
 
-  test('_computeDescriptionReadOnly', () => {
-    element.loggedIn = false;
-    element.change = {owner: {_account_id: 1}};
-    element.account = {_account_id: 1};
-    assert.equal(element._descriptionReadOnly, true);
-
-    element.loggedIn = true;
-    element.change = {owner: {_account_id: 0}};
-    element.account = {_account_id: 1};
-    assert.equal(element._descriptionReadOnly, true);
-
-    element.loggedIn = true;
-    element.change = {owner: {_account_id: 1}};
-    element.account = {_account_id: 1};
-    assert.equal(element._descriptionReadOnly, false);
-  });
-
-  test('_computeDescriptionPlaceholder', () => {
-    assert.equal(element._computeDescriptionPlaceholder(true),
-        'No patchset description');
-    assert.equal(element._computeDescriptionPlaceholder(false),
-        'Add patchset description');
-  });
-
-  test('description editing', () => {
-    const putDescStub = sinon.stub(element.$.restAPI, 'setDescription')
-        .returns(Promise.resolve({ok: true}));
-
-    element.changeNum = '42';
-    element.basePatchNum = 'PARENT';
-    element.patchNum = 1;
-
-    element.change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1, description: 'test', commit: {commit: 'rev1'}},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      actions: {},
-      owner: {_account_id: 1},
-    };
-    element.account = {_account_id: 1};
-    element.owner = {_account_id: 1};
-    element.loggedIn = true;
-
-    flush();
-
-    // The element has a description, so the account chip should be visible
-    element.owner = {_account_id: 1};
-    // and the description label should not exist.
-    const chip = element.root.querySelector('#descriptionChip');
-    let label = element.root.querySelector('#descriptionLabel');
-
-    assert.equal(chip.text, 'test');
-    assert.isNotOk(label);
-
-    // Simulate tapping the remove button, but call function directly so that
-    // can determine what happens after the promise is resolved.
-    return element._handleDescriptionRemoved()
-        .then(() => {
-          // The API stub should be called with an empty string for the new
-          // description.
-          assert.equal(putDescStub.lastCall.args[2], '');
-          assert.equal(element.change.revisions.rev1.description, '');
-
-          flush();
-          // The editable label should now be visible and the chip hidden.
-          label = element.root.querySelector('#descriptionLabel');
-          assert.isOk(label);
-          assert.equal(getComputedStyle(chip).display, 'none');
-          assert.notEqual(getComputedStyle(label).display, 'none');
-          assert.isFalse(label.readOnly);
-          // Edit the label to have a new value of test2, and save.
-          label.editing = true;
-          label._inputText = 'test2';
-          label._save();
-          flush();
-          // The API stub should be called with an `test2` for the new
-          // description.
-          assert.equal(putDescStub.callCount, 2);
-          assert.equal(putDescStub.lastCall.args[2], 'test2');
-        })
-        .then(() => {
-          flush();
-          // The chip should be visible again, and the label hidden.
-          assert.equal(element.change.revisions.rev1.description, 'test2');
-          assert.equal(getComputedStyle(label).display, 'none');
-          assert.notEqual(getComputedStyle(chip).display, 'none');
-        });
-  });
-
   test('expandAllDiffs called when expand button clicked', () => {
     element.shownFileCount = 1;
     flush();
@@ -163,7 +68,7 @@
     assert.isTrue(element._expandAllDiffs.called);
   });
 
-  test('collapseAllDiffs called when expand button clicked', () => {
+  test('collapseAllDiffs called when collapse button clicked', () => {
     element.shownFileCount = 1;
     flush();
     sinon.stub(element, '_collapseAllDiffs');
@@ -205,20 +110,29 @@
   });
 
   test('expand/collapse buttons are toggled correctly', () => {
+    // Only the expand button should be visible in the initial state when
+    // NO files are expanded.
     element.shownFileCount = 10;
     flush();
     const expandBtn = element.shadowRoot.querySelector('#expandBtn');
     const collapseBtn = element.shadowRoot.querySelector('#collapseBtn');
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
     assert.equal(getComputedStyle(collapseBtn).display, 'none');
+
+    // Both expand and collapse buttons should be visible when SOME files are
+    // expanded.
     element.filesExpanded = FilesExpandedState.SOME;
     flush();
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
-    assert.equal(getComputedStyle(collapseBtn).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();
     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();
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
@@ -249,11 +163,11 @@
 
   test('class is applied to file list on old patch set', () => {
     const allPatchSets = [{num: 4}, {num: 2}, {num: 1}];
-    assert.equal(element._computePatchInfoClass('1', allPatchSets),
+    assert.equal(element._computePatchInfoClass(1, allPatchSets),
         'patchInfoOldPatchSet');
-    assert.equal(element._computePatchInfoClass('2', allPatchSets),
+    assert.equal(element._computePatchInfoClass(2, allPatchSets),
         'patchInfoOldPatchSet');
-    assert.equal(element._computePatchInfoClass('4', allPatchSets), '');
+    assert.equal(element._computePatchInfoClass(4, allPatchSets), '');
   });
 
   suite('editMode behavior', () => {
@@ -273,14 +187,10 @@
       flush();
 
       assert.isFalse(isVisible(element.$.diffPrefsContainer));
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.descriptionContainer')));
 
       element.editMode = false;
       flush();
 
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.descriptionContainer')));
       assert.isTrue(isVisible(element.$.diffPrefsContainer));
     });
 
@@ -302,17 +212,6 @@
       assert.isFalse(isVisible(element.shadowRoot
           .querySelector('#editControls').parentElement));
     });
-
-    test('_computeUploadHelpContainerClass', () => {
-      // Only show the upload helper button when an unmerged change is viewed
-      // by its owner.
-      const accountA = {_account_id: 1};
-      const accountB = {_account_id: 2};
-      assert.notInclude(element._computeUploadHelpContainerClass(
-          {owner: accountA}, accountA), 'hide');
-      assert.include(element._computeUploadHelpContainerClass(
-          {owner: accountA}, accountB), 'hide');
-    });
   });
 });
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index e188254..4b5525a 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -23,29 +23,31 @@
 import '../../shared/gr-cursor-manager/gr-cursor-manager';
 import '../../shared/gr-icons/gr-icons';
 import '../../shared/gr-linked-text/gr-linked-text';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import '../../shared/gr-file-status-chip/gr-file-status-chip';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-file-list_html';
-import {asyncForeach} from '../../../utils/async-util';
+import {asyncForeach, debounce, DelayedTask} from '../../../utils/async-util';
 import {
   KeyboardShortcutMixin,
   Modifier,
   Shortcut,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {FilesExpandedState} from '../gr-file-list-constants';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {pluralize} from '../../../utils/string-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {appContext} from '../../../services/app-context';
-import {DiffViewMode, SpecialFilePath} from '../../../constants/constants';
-import {descendedFromClass} from '../../../utils/dom-util';
+import {
+  DiffViewMode,
+  ScrollMode,
+  SpecialFilePath,
+} from '../../../constants/constants';
+import {descendedFromClass, toggleClass} from '../../../utils/dom-util';
 import {
   addUnmodifiedFiles,
   computeDisplayPath,
@@ -54,10 +56,7 @@
   specialFilePathCompare,
 } from '../../../utils/path-list-util';
 import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
-  ConfigInfo,
-  DiffPreferencesInfo,
   ElementPropertyDeepChange,
   FileInfo,
   FileNameToFileInfoMap,
@@ -67,17 +66,16 @@
   RevisionInfo,
   UrlEncodedCommentId,
 } from '../../../types/common';
+import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
 import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {GrDiffCursor} from '../../diff/gr-diff-cursor/gr-diff-cursor';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
-import {UIDraft} from '../../../utils/comment-util';
-import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {PatchSetFile} from '../../../types/types';
 import {CustomKeyboardEvent} from '../../../types/events';
+import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
+import {Timing} from '../../../constants/reporting';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -88,36 +86,19 @@
 const SIZE_BAR_GAP_WIDTH = 1;
 const SIZE_BAR_MIN_WIDTH = 1.5;
 
-const RENDER_TIMING_LABEL = 'FileListRenderTime';
-const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
-const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
-const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
-
-const FileStatus = {
-  A: 'Added',
-  C: 'Copied',
-  D: 'Deleted',
-  M: 'Modified',
-  R: 'Renamed',
-  W: 'Rewritten',
-  U: 'Unchanged',
-};
-
 const FILE_ROW_CLASS = 'file-row';
 
 export interface GrFileList {
   $: {
-    restAPI: RestApiService & Element;
     diffPreferencesDialog: GrDiffPreferencesDialog;
     diffCursor: GrDiffCursor;
-    fileCursor: GrCursorManager;
   };
 }
 
 interface ReviewedFileInfo extends FileInfo {
   isReviewed?: boolean;
 }
-interface NormalizedFileInfo extends ReviewedFileInfo {
+export interface NormalizedFileInfo extends ReviewedFileInfo {
   __path: string;
 }
 
@@ -184,9 +165,7 @@
  */
 
 @customElement('gr-file-list')
-export class GrFileList extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrFileList extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -209,15 +188,9 @@
   @property({type: Object})
   changeComments?: ChangeComments;
 
-  @property({type: Object})
-  drafts?: {[path: string]: UIDraft[]};
-
   @property({type: Array})
   revisions?: {[revisionId: string]: RevisionInfo};
 
-  @property({type: Object})
-  projectConfig?: ConfigInfo;
-
   @property({type: Number, notify: true})
   selectedIndex = -1;
 
@@ -301,6 +274,8 @@
 
   private _cancelForEachDiff?: () => void;
 
+  loadingTask?: DelayedTask;
+
   @property({
     type: Boolean,
     computed:
@@ -334,6 +309,8 @@
 
   private readonly reporting = appContext.reportingService;
 
+  private readonly restApiService = appContext.restApiService;
+
   get keyBindings() {
     return {
       esc: '_handleEscKey',
@@ -367,15 +344,19 @@
     };
   }
 
-  /** @override */
-  created() {
-    super.created();
+  private fileCursor = new GrCursorManager();
+
+  constructor() {
+    super();
+    this.fileCursor.scrollMode = ScrollMode.KEEP_VISIBLE;
+    this.fileCursor.cursorTargetClass = 'selected';
+    this.fileCursor.focusOnMove = true;
     this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -423,9 +404,11 @@
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
+    this.fileCursor.unsetCursor();
     this._cancelDiffs();
+    this.loadingTask?.cancel();
+    super.disconnectedCallback();
   }
 
   /**
@@ -457,7 +440,7 @@
     const promises = [];
 
     promises.push(
-      this.$.restAPI
+      this.restApiService
         .getChangeOrEditFiles(changeNum, patchRange)
         .then(filesByPath => {
           this._filesByPath = filesByPath;
@@ -544,11 +527,11 @@
   }
 
   _getDiffPreferences() {
-    return this.$.restAPI.getDiffPreferences();
+    return this.restApiService.getDiffPreferences();
   }
 
   _getPreferences() {
-    return this.$.restAPI.getPreferences();
+    return this.restApiService.getPreferences();
   }
 
   private _toggleFileExpanded(file: PatchSetFile) {
@@ -571,7 +554,7 @@
       return;
     }
     // Re-render all expanded diffs sequentially.
-    this.reporting.time(EXPAND_ALL_TIMING_LABEL);
+    this.reporting.time(Timing.FILE_EXPAND_ALL);
     this._renderInOrder(
       this._expandedFiles,
       this.diffs,
@@ -619,6 +602,21 @@
   _computeCommentsString(
     changeComments?: ChangeComments,
     patchRange?: PatchRange,
+    file?: NormalizedFileInfo
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      file?.__path === undefined
+    ) {
+      return '';
+    }
+    return changeComments.computeCommentsString(patchRange, file.__path, file);
+  }
+
+  _computeDraftCount(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
     path?: string
   ) {
     if (
@@ -628,39 +626,22 @@
     ) {
       return '';
     }
-    const unresolvedCount =
-      changeComments.computeUnresolvedNum({
-        patchNum: patchRange.basePatchNum,
-        path,
-      }) +
-      changeComments.computeUnresolvedNum({
-        patchNum: patchRange.patchNum,
-        path,
-      });
-    const commentThreadCount =
-      changeComments.computeCommentThreadCount({
-        patchNum: patchRange.basePatchNum,
-        path,
-      }) +
-      changeComments.computeCommentThreadCount({
-        patchNum: patchRange.patchNum,
-        path,
-      });
-    const commentString = GrCountStringFormatter.computePluralString(
-      commentThreadCount,
-      'comment'
-    );
-    const unresolvedString = GrCountStringFormatter.computeString(
-      unresolvedCount,
-      'unresolved'
-    );
-
     return (
-      commentString +
-      // Add a space if both comments and unresolved
-      (commentString && unresolvedString ? ' ' : '') +
-      // Add parentheses around unresolved if it exists.
-      (unresolvedString ? `(${unresolvedString})` : '')
+      changeComments.computeDraftCount({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeDraftCount({
+        patchNum: patchRange.patchNum,
+        path,
+      }) +
+      changeComments.computePortedDraftCount(
+        {
+          patchNum: patchRange.patchNum,
+          basePatchNum: patchRange.basePatchNum,
+        },
+        path
+      )
     );
   }
 
@@ -672,23 +653,13 @@
     patchRange?: PatchRange,
     path?: string
   ) {
-    if (
-      changeComments === undefined ||
-      patchRange === undefined ||
-      path === undefined
-    ) {
-      return '';
-    }
-    const draftCount =
-      changeComments.computeDraftCount({
-        patchNum: patchRange.basePatchNum,
-        path,
-      }) +
-      changeComments.computeDraftCount({
-        patchNum: patchRange.patchNum,
-        path,
-      });
-    return GrCountStringFormatter.computePluralString(draftCount, 'draft');
+    const draftCount = this._computeDraftCount(
+      changeComments,
+      patchRange,
+      path
+    );
+    if (draftCount === '') return draftCount;
+    return pluralize(draftCount, 'draft');
   }
 
   /**
@@ -699,23 +670,12 @@
     patchRange?: PatchRange,
     path?: string
   ) {
-    if (
-      changeComments === undefined ||
-      patchRange === undefined ||
-      path === undefined
-    ) {
-      return '';
-    }
-    const draftCount =
-      changeComments.computeDraftCount({
-        patchNum: patchRange.basePatchNum,
-        path,
-      }) +
-      changeComments.computeDraftCount({
-        patchNum: patchRange.patchNum,
-        path,
-      });
-    return GrCountStringFormatter.computeShortString(draftCount, 'd');
+    const draftCount = this._computeDraftCount(
+      changeComments,
+      patchRange,
+      path
+    );
+    return draftCount === 0 ? '' : `${draftCount}d`;
   }
 
   /**
@@ -742,7 +702,7 @@
         patchNum: patchRange.patchNum,
         path,
       });
-    return GrCountStringFormatter.computeShortString(commentThreadCount, 'c');
+    return commentThreadCount === 0 ? '' : `${commentThreadCount}c`;
   }
 
   private _reviewFile(path: string, reviewed?: boolean) {
@@ -765,7 +725,7 @@
       throw new Error('changeNum and patchRange must be set');
     }
 
-    return this.$.restAPI.saveFileReviewed(
+    return this.restApiService.saveFileReviewed(
       this.changeNum,
       this.patchRange.patchNum,
       path,
@@ -774,14 +734,14 @@
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   _getReviewedFiles(changeNum: NumericChangeId, patchRange: PatchRange) {
     if (this.editMode) {
       return Promise.resolve([]);
     }
-    return this.$.restAPI.getReviewedFiles(changeNum, patchRange.patchNum);
+    return this.restApiService.getReviewedFiles(changeNum, patchRange.patchNum);
   }
 
   _normalizeChangeFilesResponse(
@@ -829,7 +789,7 @@
       e.preventDefault();
       // Prevent _handleFileListClick handler call
       e.stopPropagation();
-      this.$.fileCursor.setCursor(fileRow.element);
+      this.fileCursor.setCursor(fileRow.element);
       fileAction(fileRow.file);
     }
   }
@@ -843,7 +803,7 @@
   }
 
   /**
-   * Handle all events from the file list dom-repeat so event handleers don't
+   * Handle all events from the file list dom-repeat so event handlers don't
    * have to get registered for potentially very long lists.
    */
   _handleFileListClick(e: MouseEvent) {
@@ -869,7 +829,7 @@
     }
 
     e.preventDefault();
-    this.$.fileCursor.setCursor(fileRow.element);
+    this.fileCursor.setCursor(fileRow.element);
     this._toggleFileExpanded(file);
   }
 
@@ -926,13 +886,13 @@
     if (
       this.shouldSuppressKeyboardShortcut(e) ||
       this.modifierPressed(e) ||
-      this.$.fileCursor.index === -1
+      this.fileCursor.index === -1
     ) {
       return;
     }
 
     e.preventDefault();
-    this._toggleFileExpandedByIndex(this.$.fileCursor.index);
+    this._toggleFileExpandedByIndex(this.fileCursor.index);
   }
 
   _handleToggleAllInlineDiffs(e: CustomKeyboardEvent) {
@@ -950,7 +910,7 @@
     }
 
     e.preventDefault();
-    this.toggleClass('hideComments');
+    toggleClass(this, 'hideComments');
   }
 
   _handleCursorNext(e: CustomKeyboardEvent) {
@@ -968,8 +928,8 @@
         return;
       }
       e.preventDefault();
-      this.$.fileCursor.next();
-      this.selectedIndex = this.$.fileCursor.index;
+      this.fileCursor.next();
+      this.selectedIndex = this.fileCursor.index;
     }
   }
 
@@ -988,8 +948,8 @@
         return;
       }
       e.preventDefault();
-      this.$.fileCursor.previous();
-      this.selectedIndex = this.$.fileCursor.index;
+      this.fileCursor.previous();
+      this.selectedIndex = this.fileCursor.index;
     }
   }
 
@@ -998,6 +958,7 @@
       return;
     }
     e.preventDefault();
+    this.classList.remove('hideComments');
     this.$.diffCursor.createCommentInPlace();
   }
 
@@ -1083,10 +1044,10 @@
     }
 
     e.preventDefault();
-    if (!this._files[this.$.fileCursor.index]) {
+    if (!this._files[this.fileCursor.index]) {
       return;
     }
-    this._reviewFile(this._files[this.$.fileCursor.index].__path);
+    this._reviewFile(this._files[this.fileCursor.index].__path);
   }
 
   _handleToggleLeftPane(e: CustomKeyboardEvent) {
@@ -1110,28 +1071,22 @@
 
   _openCursorFile() {
     const diff = this.$.diffCursor.getTargetDiffElement();
-    if (
-      !this.change ||
-      !diff ||
-      !this.patchRange ||
-      !diff.path ||
-      !diff.patchRange
-    ) {
+    if (!this.change || !diff || !this.patchRange || !diff.path) {
       throw new Error('change, diff and patchRange must be all set and valid');
     }
     GerritNav.navigateToDiff(
       this.change,
       diff.path,
-      diff.patchRange.patchNum,
+      this.patchRange.patchNum,
       this.patchRange.basePatchNum
     );
   }
 
   _openSelectedFile(index?: number) {
     if (index !== undefined) {
-      this.$.fileCursor.setCursorAtIndex(index);
+      this.fileCursor.setCursorAtIndex(index);
     }
-    if (!this._files[this.$.fileCursor.index]) {
+    if (!this._files[this.fileCursor.index]) {
       return;
     }
     if (!this.change || !this.patchRange) {
@@ -1139,7 +1094,7 @@
     }
     GerritNav.navigateToDiff(
       this.change,
-      this._files[this.$.fileCursor.index].__path,
+      this._files[this.fileCursor.index].__path,
       this.patchRange.patchNum,
       this.patchRange.basePatchNum
     );
@@ -1164,12 +1119,6 @@
     );
   }
 
-  _computeFileStatus(
-    status?: keyof typeof FileStatus
-  ): keyof typeof FileStatus {
-    return status || 'M';
-  }
-
   _computeDiffURL(
     change?: ParsedChangeInfo,
     patchRange?: PatchRange,
@@ -1244,12 +1193,6 @@
     return classes.join(' ');
   }
 
-  _computeStatusClass(file?: NormalizedFileInfo) {
-    if (!file) return '';
-    const classStr = this._computeClass('status', file.__path);
-    return `${classStr} ${this._computeFileStatus(file.status)}`;
-  }
-
   _computePathClass(
     path: string | undefined,
     expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
@@ -1300,13 +1243,9 @@
     const files: FileNameToReviewedFileInfoMap = {...filesByPath};
     addUnmodifiedFiles(files, commentedPaths);
     const reviewedSet = new Set(reviewed || []);
-    for (const filePath in files) {
-      if (!hasOwnProperty(files, filePath)) {
-        continue;
-      }
-      files[filePath].isReviewed = reviewedSet.has(filePath);
+    for (const [filePath, reviewedFileInfo] of Object.entries(files)) {
+      reviewedFileInfo.isReviewed = reviewedSet.has(filePath);
     }
-
     this._files = this._normalizeChangeFilesResponse(files);
   }
 
@@ -1333,7 +1272,7 @@
     // Start the timer for the rendering work hwere because this is where the
     // _shownFiles property is being set, and _shownFiles is used in the
     // dom-repeat binding.
-    this.reporting.time(RENDER_TIMING_LABEL);
+    this.reporting.time(Timing.FILE_RENDER);
 
     // How many more files are being shown (if it's an increase).
     this._reportinShownFilesIncrement = Math.max(
@@ -1357,10 +1296,10 @@
   _filesChanged() {
     if (this._files && this._files.length > 0) {
       flush();
-      this.$.fileCursor.stops = Array.from(
+      this.fileCursor.stops = Array.from(
         this.root!.querySelectorAll(`.${FILE_ROW_CLASS}`)
       );
-      this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
+      this.fileCursor.setCursorAtIndex(this.selectedIndex, true);
     }
   }
 
@@ -1405,17 +1344,6 @@
   }
 
   /**
-   * Get a descriptive label for use in the status indicator's tooltip and
-   * ARIA label.
-   */
-  _computeFileStatusLabel(status?: keyof typeof FileStatus) {
-    const statusCode = this._computeFileStatus(status);
-    return hasOwnProperty(FileStatus, statusCode)
-      ? FileStatus[statusCode]
-      : 'Status Unknown';
-  }
-
-  /**
    * Converts any boolean-like variable to the string 'true' or 'false'
    *
    * This method is useful when you bind aria-checked attribute to a boolean
@@ -1492,7 +1420,7 @@
     // Required so that the newly created diff view is included in this.diffs.
     flush();
 
-    this.reporting.time(EXPAND_ALL_TIMING_LABEL);
+    this.reporting.time(Timing.FILE_EXPAND_ALL);
 
     if (newFiles.length) {
       this._renderInOrder(newFiles, this.diffs, newFiles.length);
@@ -1557,10 +1485,10 @@
             'changeComments, patchRange and diffPrefs must be set'
           );
         }
-        diffElem.comments = this.changeComments.getCommentsBySideForFile(
+
+        diffElem.threads = this.changeComments.getThreadsBySideForFile(
           file,
-          this.patchRange,
-          this.projectConfig
+          this.patchRange
         );
         const promises: Array<Promise<unknown>> = [diffElem.reload()];
         if (this._loggedIn && !this.diffPrefs.manual_review) {
@@ -1571,8 +1499,8 @@
         this._cancelForEachDiff = undefined;
         console.info('Finished expanding', initialCount, 'diff(s)');
         this.reporting.timeEndWithAverage(
-          EXPAND_ALL_TIMING_LABEL,
-          EXPAND_ALL_AVG_TIMING_LABEL,
+          Timing.FILE_EXPAND_ALL,
+          Timing.FILE_EXPAND_ALL_AVG,
           initialCount
         );
         /* Block diff cursor from auto scrolling after files are done rendering.
@@ -1647,14 +1575,9 @@
       return;
     }
 
-    // Comments are not returned with the commentSide attribute from
-    // the api, but it's necessary to be stored on the diff's
-    // comments due to use in the _handleCommentUpdate function.
-    // The comment thread already has a side associated with it, so
-    // set the comment's side to match.
-    threadEl.comments = newComments.map(c =>
-      Object.assign(c, {__commentSide: threadEl.commentSide})
-    );
+    threadEl.comments = newComments.map(c => {
+      return {...c};
+    });
     flush();
   }
 
@@ -1672,8 +1595,8 @@
    * are reasonably fast.
    */
   _loadingChanged(loading?: boolean) {
-    this.debounce(
-      'loading-change',
+    this.loadingTask = debounce(
+      this.loadingTask,
       () => {
         // Only show set the loading if there have been files loaded to show. In
         // this way, the gray loading style is not shown on initial loads.
@@ -1855,10 +1778,10 @@
    */
   _reportRenderedRow(index: number) {
     if (index === this._shownFiles.length - 1) {
-      this.async(() => {
+      setTimeout(() => {
         this.reporting.timeEndWithAverage(
-          RENDER_TIMING_LABEL,
-          RENDER_AVG_TIMING_LABEL,
+          Timing.FILE_RENDER,
+          Timing.FILE_RENDER_AVG,
           this._reportinShownFilesIncrement
         );
       }, 1);
@@ -1893,6 +1816,16 @@
   _computeTruncatedPath(path: string) {
     return computeTruncatedPath(path);
   }
+
+  _getOldPath(file: NormalizedFileInfo) {
+    // The gr-endpoint-decorator is waiting until all gr-endpoint-param
+    // values are updated.
+    // The old_path property is undefined for added files, and the
+    // gr-endpoint-param value bound to file.old_path is never updates.
+    // As a results, the gr-endpoint-decorator doesn't work for added files.
+    // As a workaround, this method returns null instead of undefined.
+    return file.old_path ?? null;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
index d93ce68..89983ad 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
@@ -80,27 +80,6 @@
       text-align: left;
       width: 1.5em;
     }
-    .status {
-      display: inline-block;
-      border-radius: var(--border-radius);
-      margin-left: var(--spacing-s);
-      padding: 0 var(--spacing-m);
-      color: var(--primary-text-color);
-      font-size: var(--font-size-small);
-      background-color: var(--dark-add-highlight-color);
-    }
-    .status.invisible,
-    .status.M {
-      display: none;
-    }
-    .status.D,
-    .status.R,
-    .status.W {
-      background-color: var(--dark-remove-highlight-color);
-    }
-    .status.U {
-      background-color: var(--comment-background-color);
-    }
     .file-row {
       cursor: pointer;
     }
@@ -175,7 +154,7 @@
       padding-left: var(--spacing-s);
     }
     .drafts {
-      color: #c62828;
+      color: var(--error-foreground);
       font-weight: var(--font-weight-bold);
     }
     .show-hide-icon:focus {
@@ -396,6 +375,8 @@
                 </gr-endpoint-param>
                 <gr-endpoint-param name="path" value="[[file.__path]]">
                 </gr-endpoint-param>
+                <gr-endpoint-param name="oldPath" value="[[_getOldPath(file)]]">
+                </gr-endpoint-param>
               </gr-endpoint-decorator>
             </template>
           </template>
@@ -421,14 +402,7 @@
               >
                 [[_computeTruncatedPath(file.__path)]]
               </span>
-              <span
-                class$="[[_computeStatusClass(file)]]"
-                tabindex="0"
-                title$="[[_computeFileStatusLabel(file.status)]]"
-                aria-label$="[[_computeFileStatusLabel(file.status)]]"
-              >
-                [[_computeFileStatusLabel(file.status)]]
-              </span>
+              <gr-file-status-chip file="[[file]]"></gr-file-status-chip>
               <gr-copy-clipboard
                 hide-input=""
                 text="[[file.__path]]"
@@ -456,8 +430,7 @@
               >
               <span
                 ><!--
-              -->[[_computeCommentsString(changeComments, patchRange,
-                file.__path)]]<!--
+              -->[[_computeCommentsString(changeComments, patchRange, file)]]<!--
            --></span
               >
               <span class="noCommentsScreenReaderText">
@@ -763,13 +736,5 @@
     on-reload-diff-preference="_handleReloadingDiffPreference"
   >
   </gr-diff-preferences-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
   <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
-  <gr-cursor-manager
-    id="fileCursor"
-    scroll-mode="keep-visible"
-    focus-on-move=""
-    cursor-target-class="selected"
-  ></gr-cursor-manager>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index d85ae4d..b8ba86c 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -16,7 +16,6 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import {listenOnce} from '../../../test/test-utils.js';
 import '../../diff/gr-comment-api/gr-comment-api.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import './gr-file-list.js';
@@ -26,9 +25,10 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {runA11yAudit} from '../../../test/a11y-test-utils.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
+import {TestKeyboardShortcutBinder, stubRestApi, spyRestApi, listenOnce} from '../../../test/test-utils.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api.js';
+import {createCommentThreads} from '../../../utils/comment-util.js';
+import {createChangeComments} from '../../../test/test-data-generators.js';
 
 const commentApiMock = createCommentApiMockWithTemplateElement(
     'gr-file-list-comment-api-mock', html`
@@ -79,22 +79,16 @@
 
   suite('basic tests', () => {
     setup(done => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getPreferences() { return Promise.resolve({}); },
-        getDiffPreferences() { return Promise.resolve({}); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-        getAccountCapabilities() { return Promise.resolve({}); },
-      });
-      stub('gr-date-formatter', {
-        _loadTimeFormat() { return Promise.resolve(''); },
-      });
-      stub('gr-diff-host', {
-        reload() { return Promise.resolve(); },
-        prefetchDiff() {},
-      });
+      stubRestApi('getPreferences').returns(Promise.resolve({}));
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+      stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
+      stub('gr-date-formatter', '_loadTimeFormat').callsFake(() =>
+        Promise.resolve('')
+      );
+      stub('gr-diff-host', 'reload').callsFake(() => Promise.resolve());
+      stub('gr-diff-host', 'prefetchDiff').callsFake(() => {});
 
       // Element must be wrapped in an element with direct access to the
       // comment API.
@@ -106,8 +100,6 @@
       // been initialized.
       commentApiWrapper.loadComments().then(() => {
         sinon.stub(element.changeComments, 'getPaths').returns({});
-        sinon.stub(element.changeComments, 'getCommentsBySideForPath')
-            .returns({meta: {}, left: [], right: []});
         done();
       });
       element._loading = false;
@@ -310,11 +302,8 @@
         '-1073741824': '-1 GiB',
         '0': '+/-0 B',
       };
-
-      for (const bytes in table) {
-        if (table.hasOwnProperty(bytes)) {
-          assert.equal(element._formatBytes(Number(bytes)), table[bytes]);
-        }
+      for (const [bytes, expected] of Object.entries(table)) {
+        assert.equal(element._formatBytes(Number(bytes)), expected);
       }
     });
 
@@ -353,101 +342,7 @@
     });
 
     test('comment filtering', () => {
-      const comments = {
-        '/COMMIT_MSG': [
-          {
-            patch_set: 1,
-            message: 'Done',
-            updated: '2017-02-08 16:40:49',
-            id: '1',
-          },
-          {
-            patch_set: 1,
-            message: 'oh hay',
-            updated: '2017-02-09 16:40:49',
-            id: '2',
-          },
-          {
-            patch_set: 2,
-            message: 'hello',
-            updated: '2017-02-10 16:40:49',
-            id: '3',
-          },
-        ],
-        'myfile.txt': [
-          {
-            patch_set: 1,
-            message: 'good news!',
-            updated: '2017-02-08 16:40:49',
-            id: '4',
-          },
-          {
-            patch_set: 2,
-            message: 'wat!?',
-            updated: '2017-02-09 16:40:49',
-            id: '5',
-          },
-          {
-            patch_set: 2,
-            message: 'hi',
-            updated: '2017-02-10 16:40:49',
-            id: '6',
-          },
-        ],
-        'unresolved.file': [
-          {
-            patch_set: 2,
-            message: 'wat!?',
-            updated: '2017-02-09 16:40:49',
-            id: '7',
-            unresolved: true,
-          },
-          {
-            patch_set: 2,
-            message: 'hi',
-            updated: '2017-02-10 16:40:49',
-            id: '8',
-            in_reply_to: '7',
-            unresolved: false,
-          },
-          {
-            patch_set: 2,
-            message: 'good news!',
-            updated: '2017-02-08 16:40:49',
-            id: '9',
-            unresolved: true,
-          },
-        ],
-      };
-      const drafts = {
-        '/COMMIT_MSG': [
-          {
-            patch_set: 1,
-            message: 'hi',
-            updated: '2017-02-15 16:40:49',
-            id: '10',
-            unresolved: true,
-          },
-          {
-            patch_set: 1,
-            message: 'fyi',
-            updated: '2017-02-15 16:40:49',
-            id: '11',
-            unresolved: false,
-          },
-        ],
-        'unresolved.file': [
-          {
-            patch_set: 1,
-            message: 'hi',
-            updated: '2017-02-11 16:40:49',
-            id: '12',
-            unresolved: false,
-          },
-        ],
-      };
-      element.changeComments = new ChangeComments(comments, {}, drafts, 123);
-
+      element.changeComments = createChangeComments();
       const parentTo1 = {
         basePatchNum: 'PARENT',
         patchNum: 1,
@@ -464,12 +359,6 @@
       };
 
       assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo1,
-              '/COMMIT_MSG', 'comment'), '2 comments (1 unresolved)');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
-      assert.equal(
           element._computeCommentsStringMobile(element.changeComments, parentTo1
               , '/COMMIT_MSG'), '2c');
       assert.equal(
@@ -488,12 +377,6 @@
           element._computeDraftsStringMobile(element.changeComments, _1To2,
               'unresolved.file'), '1d');
       assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo1,
-              'myfile.txt', 'comment'), '1 comment');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'myfile.txt', 'comment'), '3 comments');
-      assert.equal(
           element._computeCommentsStringMobile(
               element.changeComments,
               parentTo1,
@@ -515,12 +398,6 @@
           element._computeDraftsStringMobile(element.changeComments, _1To2,
               'myfile.txt'), '');
       assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo1,
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
           element._computeCommentsStringMobile(
               element.changeComments,
               parentTo1,
@@ -542,12 +419,6 @@
           element._computeDraftsStringMobile(element.changeComments, _1To2,
               'file_added_in_rev2.txt'), '');
       assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo2,
-              '/COMMIT_MSG', 'comment'), '1 comment');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
-      assert.equal(
           element._computeCommentsStringMobile(
               element.changeComments,
               parentTo2,
@@ -572,12 +443,6 @@
           element._computeDraftsStringMobile(element.changeComments, _1To2,
               '/COMMIT_MSG'), '2d');
       assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo2,
-              'myfile.txt', 'comment'), '2 comments');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'myfile.txt', 'comment'), '3 comments');
-      assert.equal(
           element._computeCommentsStringMobile(
               element.changeComments,
               parentTo2,
@@ -592,18 +457,6 @@
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, _1To2,
               'myfile.txt'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo2,
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo2,
-              'unresolved.file', 'comment'), '2 comments (1 unresolved)');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'unresolved.file', 'comment'), '2 comments (1 unresolved)');
     });
 
     test('_reviewedTitle', () => {
@@ -627,7 +480,7 @@
           patchNum: 2,
         };
         element.change = {_number: 42};
-        element.$.fileCursor.setCursorAtIndex(0);
+        element.fileCursor.setCursorAtIndex(0);
       });
 
       test('toggle left diff via shortcut', () => {
@@ -645,38 +498,38 @@
         flush();
 
         const items = [...element.root.querySelectorAll('.file-row')];
-        element.$.fileCursor.stops = items;
-        element.$.fileCursor.setCursorAtIndex(0);
+        element.fileCursor.stops = items;
+        element.fileCursor.setCursorAtIndex(0);
         assert.equal(items.length, 3);
         assert.isTrue(items[0].classList.contains('selected'));
         assert.isFalse(items[1].classList.contains('selected'));
         assert.isFalse(items[2].classList.contains('selected'));
         // j with a modifier should not move the cursor.
         MockInteractions.pressAndReleaseKeyOn(element, 74, 'shift', 'j');
-        assert.equal(element.$.fileCursor.index, 0);
+        assert.equal(element.fileCursor.index, 0);
         // down should not move the cursor.
         MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
-        assert.equal(element.$.fileCursor.index, 0);
+        assert.equal(element.fileCursor.index, 0);
 
         MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-        assert.equal(element.$.fileCursor.index, 1);
+        assert.equal(element.fileCursor.index, 1);
         assert.equal(element.selectedIndex, 1);
         MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
 
         const navStub = sinon.stub(GerritNav, 'navigateToDiff');
-        assert.equal(element.$.fileCursor.index, 2);
+        assert.equal(element.fileCursor.index, 2);
         assert.equal(element.selectedIndex, 2);
 
         // k with a modifier should not move the cursor.
         MockInteractions.pressAndReleaseKeyOn(element, 75, 'shift', 'k');
-        assert.equal(element.$.fileCursor.index, 2);
+        assert.equal(element.fileCursor.index, 2);
 
         // up should not move the cursor.
         MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'down');
-        assert.equal(element.$.fileCursor.index, 2);
+        assert.equal(element.fileCursor.index, 2);
 
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.$.fileCursor.index, 1);
+        assert.equal(element.fileCursor.index, 1);
         assert.equal(element.selectedIndex, 1);
         MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
 
@@ -687,7 +540,7 @@
         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.fileCursor.index, 0);
         assert.equal(element.selectedIndex, 0);
 
         const createCommentInPlaceStub = sinon.stub(element.$.diffCursor,
@@ -701,8 +554,8 @@
         sinon.stub(element, '_expandedFilesChanged');
         flush();
         const files = [...element.root.querySelectorAll('.file-row')];
-        element.$.fileCursor.stops = files;
-        element.$.fileCursor.setCursorAtIndex(0);
+        element.fileCursor.stops = files;
+        element.fileCursor.setCursorAtIndex(0);
         assert.equal(element.diffs.length, 0);
         assert.equal(element._expandedFiles.length, 0);
 
@@ -718,7 +571,7 @@
         assert.equal(element.diffs.length, 0);
         assert.equal(element._expandedFiles.length, 0);
 
-        element.$.fileCursor.setCursorAtIndex(1);
+        element.fileCursor.setCursorAtIndex(1);
         MockInteractions.keyUpOn(element, 73, null, 'i');
         flush();
         assert.equal(element.diffs.length, 1);
@@ -730,12 +583,8 @@
         flush();
         assert.equal(element.diffs.length, paths.length);
         assert.equal(element._expandedFiles.length, paths.length);
-        for (const index in element.diffs) {
-          if (!element.diffs.hasOwnProperty(index)) { continue; }
-          assert.isTrue(
-              element._expandedFiles
-                  .some(f => f.path === element.diffs[index].path)
-          );
+        for (const diff of element.diffs) {
+          assert.isTrue(element._expandedFiles.some(f => f.path === diff.path));
         }
 
         MockInteractions.keyUpOn(element, 73, 'shift', 'i');
@@ -836,16 +685,6 @@
       });
     });
 
-    test('computed properties', () => {
-      assert.equal(element._computeFileStatus('A'), 'A');
-      assert.equal(element._computeFileStatus(undefined), 'M');
-      assert.equal(element._computeFileStatus(null), 'M');
-
-      assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
-      assert.equal(element._computeClass('clazz', '/COMMIT_MSG'),
-          'clazz invisible');
-    });
-
     test('file review status', () => {
       element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
       element._filesByPath = {
@@ -859,7 +698,7 @@
         basePatchNum: 'PARENT',
         patchNum: 2,
       };
-      element.$.fileCursor.setCursorAtIndex(0);
+      element.fileCursor.setCursorAtIndex(0);
       const reviewSpy = sinon.spy(element, '_reviewFile');
       const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
 
@@ -898,11 +737,6 @@
       assert.isFalse(toggleExpandSpy.called);
     });
 
-    test('_computeFileStatusLabel', () => {
-      assert.equal(element._computeFileStatusLabel('A'), 'Added');
-      assert.equal(element._computeFileStatusLabel('M'), 'Modified');
-    });
-
     test('_handleFileListClick', () => {
       element._filesByPath = {
         '/COMMIT_MSG': {},
@@ -970,7 +804,7 @@
         basePatchNum: 'PARENT',
         patchNum: 2,
       };
-      element.$.fileCursor.setCursorAtIndex(0);
+      element.fileCursor.setCursorAtIndex(0);
       sinon.stub(element, '_expandedFilesChanged');
       flush();
       const fileRows =
@@ -998,7 +832,7 @@
         patchNum: 2,
       };
       sinon.spy(element, '_updateDiffPreferences');
-      element.$.fileCursor.setCursorAtIndex(0);
+      element.fileCursor.setCursorAtIndex(0);
       flush();
 
       // Tap on a file to generate the diff.
@@ -1267,13 +1101,13 @@
 
       element.reload().then(() => {
         assert.isFalse(element._loading);
-        element.flushDebouncer('loading-change');
+        element.loadingTask.flush();
         assert.isFalse(element.classList.contains('loading'));
         done();
       });
       assert.isTrue(element._loading);
       assert.isFalse(element.classList.contains('loading'));
-      element.flushDebouncer('loading-change');
+      element.loadingTask.flush();
       assert.isTrue(element.classList.contains('loading'));
     });
 
@@ -1283,7 +1117,7 @@
       element.patchRange = {patchNum: 12};
       element.reload();
       assert.isTrue(element._loading);
-      element.flushDebouncer('loading-change');
+      element.loadingTask.flush();
       assert.isFalse(element.classList.contains('loading'));
     });
   });
@@ -1406,7 +1240,7 @@
       // are no deletions.
       assert.equal(element._computeBarAdditionWidth(file, stats), 30);
 
-      // If there are no insetions, there is no width.
+      // If there are no insertions, there is no width.
       stats.maxInserted = 0;
       assert.equal(element._computeBarAdditionWidth(file, stats), 0);
 
@@ -1498,6 +1332,7 @@
     const commitMsgComments = [
       {
         patch_set: 2,
+        path: '/p',
         id: 'ecf0b9fa_fe1a5f62',
         line: 20,
         updated: '2018-02-08 18:49:18.000000000',
@@ -1506,6 +1341,7 @@
       },
       {
         patch_set: 2,
+        path: '/p',
         id: '503008e2_0ab203ee',
         line: 10,
         updated: '2018-02-14 22:07:43.000000000',
@@ -1514,6 +1350,7 @@
       },
       {
         patch_set: 2,
+        path: '/p',
         id: 'cc788d2c_cb1d728c',
         line: 20,
         in_reply_to: 'ecf0b9fa_fe1a5f62',
@@ -1524,17 +1361,8 @@
     ];
 
     async function setupDiff(diff) {
-      diff.comments = {
-        left: diff.path === '/COMMIT_MSG' ? commitMsgComments : [],
-        right: [],
-        meta: {
-          changeNum: 1,
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: 2,
-          },
-        },
-      };
+      diff.threads = diff.path === '/COMMIT_MSG' ?
+        createCommentThreads(commitMsgComments) : [];
       diff.prefs = {
         context: 10,
         tab_size: 8,
@@ -1542,18 +1370,16 @@
         line_length: 100,
         cursor_blink_rate: 0,
         line_wrapping: false,
-        intraline_difference: true,
         show_line_endings: true,
         show_tabs: true,
         show_whitespace_errors: true,
         syntax_highlighting: true,
-        auto_hide_diff_table_header: true,
         theme: 'DEFAULT',
         ignore_whitespace: 'IGNORE_NONE',
       };
       diff.diff = getMockDiffResponse();
       commentApiWrapper.loadComments().then(() => {
-        sinon.stub(element.changeComments, 'getCommentsBySideForPath')
+        sinon.stub(element.changeComments, 'getCommentsForPath')
             .withArgs('/COMMIT_MSG', {
               basePatchNum: 'PARENT',
               patchNum: 2,
@@ -1577,20 +1403,15 @@
     }
 
     setup(done => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getPreferences() { return Promise.resolve({}); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-      });
-      stub('gr-date-formatter', {
-        _loadTimeFormat() { return Promise.resolve(''); },
-      });
-      stub('gr-diff-host', {
-        reload() { return Promise.resolve(); },
-        prefetchDiff() {},
-      });
+      stubRestApi('getPreferences').returns(Promise.resolve({}));
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+      stub('gr-date-formatter', '_loadTimeFormat').callsFake(() =>
+        Promise.resolve('')
+      );
+      stub('gr-diff-host', 'reload').callsFake(() => Promise.resolve());
+      stub('gr-diff-host', 'prefetchDiff').callsFake(() => {});
 
       // Element must be wrapped in an element with direct access to the
       // comment API.
@@ -1605,8 +1426,6 @@
       // been initialized.
       commentApiWrapper.loadComments().then(() => {
         sinon.stub(element.changeComments, 'getPaths').returns({});
-        sinon.stub(element.changeComments, 'getCommentsBySideForPath')
-            .returns({meta: {}, left: [], right: []});
         done();
       });
       element._loading = false;
@@ -1664,7 +1483,7 @@
       assert.isFalse(diffStops[11].classList.contains('target-row'));
 
       // The file cursor is now at 1.
-      assert.equal(element.$.fileCursor.index, 1);
+      assert.equal(element.fileCursor.index, 1);
       MockInteractions.keyUpOn(element, 73, null, 'i');
       flush();
 
@@ -1706,7 +1525,7 @@
       assert.isTrue(diffStops[11].classList.contains('target-row'));
 
       // The file cursor is still at 0.
-      assert.equal(element.$.fileCursor.index, 0);
+      assert.equal(element.fileCursor.index, 0);
     });
 
     suite('n key presses', () => {
@@ -1834,7 +1653,7 @@
       });
 
       test('_getReviewedFiles does not call API', () => {
-        const apiSpy = sinon.spy(element.$.restAPI, 'getReviewedFiles');
+        const apiSpy = spyRestApi('getReviewedFiles');
         element.editMode = true;
         return element._getReviewedFiles().then(files => {
           assert.equal(files.length, 0);
@@ -1888,6 +1707,7 @@
       const commentStubRes1 = [
         {
           patch_set: 2,
+          path: '/p',
           id: '503008e2_0ab203ee',
           line: 20,
           updated: '2018-02-08 18:49:18.000000000',
@@ -1898,6 +1718,7 @@
       const commentStubRes2 = [
         {
           patch_set: 2,
+          path: '/p',
           id: 'ecf0b9fa_fe1a5f62',
           line: 20,
           updated: '2018-02-08 18:49:18.000000000',
@@ -1906,6 +1727,7 @@
         },
         {
           patch_set: 2,
+          path: '/p',
           id: '503008e2_0ab203ee',
           line: 10,
           in_reply_to: 'ecf0b9fa_fe1a5f62',
@@ -1915,6 +1737,7 @@
         },
         {
           patch_set: 2,
+          path: '/p',
           id: '503008e2_0ab203ef',
           line: 20,
           in_reply_to: '503008e2_0ab203ee',
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
index 1957f5c..c11e484 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -17,20 +17,11 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-included-in-dialog_html';
 import {customElement, property} from '@polymer/decorators';
 import {IncludedInInfo, NumericChangeId} from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-
-export interface GrIncludedInDialog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
 
 interface DisplayGroup {
   title: string;
@@ -38,9 +29,7 @@
 }
 
 @customElement('gr-included-in-dialog')
-export class GrIncludedInDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrIncludedInDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -63,18 +52,22 @@
   @property({type: String})
   _filterText = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   loadData() {
     if (!this.changeNum) {
       return Promise.reject(new Error('missing required property changeNum'));
     }
     this._filterText = '';
-    return this.$.restAPI.getChangeIncludedIn(this.changeNum).then(configs => {
-      if (!configs) {
-        return;
-      }
-      this._includedIn = configs;
-      this._loaded = true;
-    });
+    return this.restApiService
+      .getChangeIncludedIn(this.changeNum)
+      .then(configs => {
+        if (!configs) {
+          return;
+        }
+        this._includedIn = configs;
+        this._loaded = true;
+      });
   }
 
   _resetData() {
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.ts
index 8e90f3b..2029209 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.ts
@@ -100,5 +100,4 @@
       </ul>
     </div>
   </template>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
index 60a6058..b4fa4a8 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
@@ -18,8 +18,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../../styles/gr-voting-styles';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-label-score-row_html';
 import {customElement, property} from '@polymer/decorators';
@@ -56,9 +54,7 @@
 }
 
 @customElement('gr-label-score-row')
-export class GrLabelScoreRow extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrLabelScoreRow extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
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 312532e..c53f386 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
@@ -62,12 +62,40 @@
     }
     gr-button.iron-selected[vote='positive'] {
       --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'] {
       --button-background-color: var(--vote-color-rejected);
     }
     gr-button.iron-selected[vote='negative'] {
       --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'] {
       --button-background-color: var(--vote-color-neutral);
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index d528192..4147cc0 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -14,11 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-label-score-row/gr-label-score-row';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-label-scores_html';
 import {customElement, property} from '@polymer/decorators';
@@ -33,21 +30,22 @@
 } from '../../../types/common';
 import {
   GrLabelScoreRow,
+  Label,
   LabelValuesMap,
 } from '../gr-label-score-row/gr-label-score-row';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {appContext} from '../../../services/app-context';
+import {labelCompare} from '../../../utils/label-util';
+import {Execution} from '../../../constants/reporting';
 
-type Labels = {[label: string]: number};
 @customElement('gr-label-scores')
-export class GrLabelScores extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrLabelScores extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
 
   @property({type: Array, computed: '_computeLabels(change.labels.*, account)'})
-  _labels?: Labels;
+  _labels: Label[] = [];
 
   @property({type: Object, observer: '_computeColumns'})
   permittedLabels?: LabelNameToValueMap;
@@ -61,57 +59,29 @@
   @property({type: Object})
   _labelValues?: LabelValuesMap;
 
+  private readonly reporting = appContext.reportingService;
+
   getLabelValues(includeDefaults = true): LabelNameToValuesMap {
     const labels: LabelNameToValuesMap = {};
     if (this.shadowRoot === null || !this.change) {
       return labels;
     }
-    for (const label in this.permittedLabels) {
-      if (!hasOwnProperty(this.permittedLabels, label)) {
-        continue;
-      }
-
+    for (const label of Object.keys(this.permittedLabels ?? {})) {
       const selectorEl = this.shadowRoot.querySelector(
         `gr-label-score-row[name="${label}"]`
       ) as null | GrLabelScoreRow;
-      if (!selectorEl) {
-        continue;
-      }
-
-      // The user may have not voted on this label.
-      if (!selectorEl.selectedItem) {
-        continue;
-      }
+      if (!selectorEl?.selectedItem) continue;
 
       const selectedVal =
         typeof selectorEl.selectedValue === 'string'
           ? Number(selectorEl.selectedValue)
           : selectorEl.selectedValue;
 
-      if (selectedVal === undefined) {
-        continue;
-      }
-
-      // Only send the selection if the user changed it.
-      const prevVal = this._getVoteForAccount(
-        this.change.labels,
-        label,
-        this.account
-      );
-
-      let prevValNum: number | null | undefined;
-      if (typeof prevVal === 'string') {
-        prevValNum = Number(prevVal);
-      } else {
-        prevValNum = prevVal;
-      }
+      if (selectedVal === undefined) continue;
 
       const defValNum = this._getDefaultValue(this.change.labels, label);
-
-      if (selectedVal !== prevValNum) {
-        if (includeDefaults || !!prevValNum || selectedVal !== defValNum) {
-          labels[label] = selectedVal;
-        }
+      if (includeDefaults || selectedVal !== defValNum) {
+        labels[label] = selectedVal;
       }
     }
     return labels;
@@ -121,13 +91,21 @@
     labels: LabelNameToInfoMap,
     labelName: string,
     numberValue?: number
-  ) {
-    for (const k in (labels[labelName] as DetailedLabelInfo).values) {
-      if (Number(k) === numberValue) {
-        return k;
+  ): string {
+    const detailedInfo = labels[labelName] as DetailedLabelInfo;
+    if (detailedInfo.values) {
+      for (const labelValue of Object.keys(detailedInfo.values)) {
+        if (Number(labelValue) === numberValue) {
+          return labelValue;
+        }
       }
     }
-    return numberValue;
+    const stringVal = `${numberValue}`;
+    this.reporting.reportExecution(Execution.REACHABLE_CODE, {
+      value: stringVal,
+      id: 'label-value-not-found',
+    });
+    return stringVal;
   }
 
   _getDefaultValue(labels?: LabelNameToInfoMap, labelName?: string) {
@@ -140,7 +118,7 @@
     labels: LabelNameToInfoMap | undefined,
     labelName: string,
     account?: AccountInfo
-  ) {
+  ): string | null {
     if (!labels) return null;
     const votes = labels[labelName] as DetailedLabelInfo;
     if (votes.all && votes.all.length > 0) {
@@ -165,18 +143,12 @@
       LabelNameToInfoMap
     >,
     account?: AccountInfo
-  ) {
-    // Polymer 2: check for undefined
-    if ([labelRecord, account].includes(undefined)) {
-      return undefined;
-    }
-
+  ): Label[] {
+    if (!account) return [];
+    if (!labelRecord?.base) return [];
     const labelsObj = labelRecord.base;
-    if (!labelsObj) {
-      return [];
-    }
     return Object.keys(labelsObj)
-      .sort()
+      .sort(labelCompare)
       .map(key => {
         return {
           name: key,
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
index ae639e1..ef123c9 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-label-scores.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-label-scores');
 
@@ -24,9 +25,7 @@
   let element;
 
   setup(done => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-    });
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     element = basicFixture.instantiate();
     element.change = {
       _number: '123',
@@ -86,12 +85,10 @@
   });
 
   test('get and set label scores', () => {
-    for (const label in element.permittedLabels) {
-      if (element.permittedLabels.hasOwnProperty(label)) {
-        const row = element.shadowRoot
-            .querySelector('gr-label-score-row[name="' + label + '"]');
-        row.setSelectedValue(-1);
-      }
+    for (const label of Object.keys(element.permittedLabels)) {
+      const row = element.shadowRoot
+          .querySelector('gr-label-score-row[name="' + label + '"]');
+      row.setSelectedValue(-1);
     }
     assert.deepEqual(element.getLabelValues(), {
       'Code-Review': -1,
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 8b0cf4b..3ea9f68 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -21,14 +21,11 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-formatted-text/gr-formatted-text';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import '../../../styles/gr-voting-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-message_html';
-import {SpecialFilePath} from '../../../constants/constants';
+import {MessageTag, SpecialFilePath} from '../../../constants/constants';
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {
   ChangeInfo,
@@ -40,13 +37,26 @@
   VotingRangeInfo,
   NumericChangeId,
   ChangeMessageId,
+  PatchSetNum,
+  AccountInfo,
+  BasePatchSetNum,
 } from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {CommentThread} from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {appContext} from '../../../services/app-context';
+import {pluralize} from '../../../utils/string-util';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+  computePredecessor,
+} from '../../../utils/patch-set-util';
+import {isServiceUser} from '../../../utils/account-util';
 
-const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?(?:P|p)atch (?:S|s)et \d+:\s*(.*)/;
+const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?[Pp]atch [Ss]et \d+:\s*(.*)/;
 const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.]?$/;
+const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
+const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -58,13 +68,7 @@
   id: ChangeMessageId;
 }
 
-export interface GrMessage {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
-interface ChangeMessage extends ChangeMessageInfo {
+export interface ChangeMessage extends ChangeMessageInfo {
   // TODO(TS): maybe should be an enum instead
   type: string;
   expanded: boolean;
@@ -79,9 +83,7 @@
 }
 
 @customElement('gr-message')
-export class GrMessage extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrMessage extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -192,20 +194,22 @@
   })
   _commentCountText = '';
 
-  created() {
-    super.created();
+  private readonly restApiService = appContext.restApiService;
+
+  constructor() {
+    super();
     this.addEventListener('click', e => this._handleClick(e));
   }
 
-  attached() {
-    super.attached();
-    this.$.restAPI.getConfig().then(config => {
+  connectedCallback() {
+    super.connectedCallback();
+    this.restApiService.getConfig().then(config => {
       this.config = config;
     });
-    this.$.restAPI.getLoggedIn().then(loggedIn => {
+    this.restApiService.getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
-    this.$.restAPI.getIsAdmin().then(isAdmin => {
+    this.restApiService.getIsAdmin().then(isAdmin => {
       this._isAdmin = !!isAdmin;
     });
   }
@@ -220,31 +224,15 @@
   }
 
   _computeCommentCountText(threadsLength?: number) {
-    if (threadsLength === 0) {
+    if (!threadsLength) {
       return undefined;
-    } else if (threadsLength === 1) {
-      return '1 comment';
-    } else {
-      return `${threadsLength} comments`;
     }
-  }
 
-  _onThreadListModified() {
-    // TODO(taoalpha): this won't propagate the changes to the files
-    // should consider replacing this with either top level events
-    // or gerrit level events
-
-    // emit the event so change-view can also get updated with latest changes
-    this.dispatchEvent(
-      new CustomEvent('comment-refresh', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    return pluralize(threadsLength, 'comment');
   }
 
   _computeMessageContentExpanded(content?: string, tag?: ReviewInputTag) {
-    return this._computeMessageContent(content, tag, true);
+    return this._computeMessageContent(true, content, tag);
   }
 
   _patchsetCommentSummary(commentThreads: CommentThread[] = []) {
@@ -276,18 +264,66 @@
     tag?: ReviewInputTag,
     commentThreads?: CommentThread[]
   ) {
-    const summary = this._computeMessageContent(content, tag, false);
+    const summary = this._computeMessageContent(false, content, tag);
     if (summary || !commentThreads) return summary;
     return this._patchsetCommentSummary(commentThreads);
   }
 
+  _showViewDiffButton(message?: ChangeMessage) {
+    return (
+      this._isNewPatchsetTag(message?.tag) || this._isMergePatchset(message)
+    );
+  }
+
+  _isMergePatchset(message?: ChangeMessage) {
+    return (
+      message?.tag === MessageTag.TAG_MERGED &&
+      message?.message.match(MERGED_PATCHSET_PATTERN)
+    );
+  }
+
+  _isNewPatchsetTag(tag?: ReviewInputTag) {
+    return (
+      tag === MessageTag.TAG_NEW_PATCHSET ||
+      tag === MessageTag.TAG_NEW_WIP_PATCHSET
+    );
+  }
+
+  _handleViewPatchsetDiff(e: Event) {
+    if (!this.message || !this.change) return;
+    let patchNum: PatchSetNum;
+    let basePatchNum: PatchSetNum;
+    if (this.message.message.match(UPLOADED_NEW_PATCHSET_PATTERN)) {
+      const match = this.message.message.match(UPLOADED_NEW_PATCHSET_PATTERN)!;
+      if (isNaN(Number(match[1])))
+        throw new Error('invalid patchnum in message');
+      patchNum = Number(match[1]) as PatchSetNum;
+      basePatchNum = computePredecessor(patchNum)!;
+    } else if (this.message.message.match(MERGED_PATCHSET_PATTERN)) {
+      const match = this.message.message.match(MERGED_PATCHSET_PATTERN)!;
+      if (isNaN(Number(match[1])))
+        throw new Error('invalid patchnum in message');
+      basePatchNum = Number(match[1]) as BasePatchSetNum;
+      patchNum = computeLatestPatchNum(computeAllPatchSets(this.change))!;
+    } else {
+      // Message is of the form "Commit Message was updated" or "Patchset X
+      // was rebased"
+      patchNum = computeLatestPatchNum(computeAllPatchSets(this.change))!;
+      basePatchNum = computePredecessor(patchNum)!;
+    }
+    GerritNav.navigateToChange(this.change, patchNum, basePatchNum);
+    // stop propagation to stop message expansion
+    e.stopPropagation();
+  }
+
   _computeMessageContent(
-    content = '',
-    tag: ReviewInputTag = '' as ReviewInputTag,
-    isExpanded: boolean
+    isExpanded: boolean,
+    content?: string,
+    tag?: ReviewInputTag
   ) {
-    const isNewPatchSet =
-      tag.endsWith(':newPatchSet') || tag.endsWith(':newWipPatchSet');
+    if (!content) return '';
+    const isNewPatchSet = this._isNewPatchsetTag(tag);
+
     const lines = content.split('\n');
     const filteredLines = lines.filter(line => {
       if (!isExpanded && line.startsWith('>')) {
@@ -435,16 +471,17 @@
     return classes.join(' ');
   }
 
-  _computeClass(expanded: boolean) {
+  _computeClass(expanded?: boolean, author?: AccountInfo) {
     const classes = [];
     classes.push(expanded ? 'expanded' : 'collapsed');
+    if (isServiceUser(author)) classes.push('serviceUser');
     return classes.join(' ');
   }
 
   _handleAnchorClick(e: Event) {
     e.preventDefault();
     // The element which triggers _handleAnchorClick is rendered only if
-    // message.id defined: the elemenet is wrapped in dom-if if="[[message.id]]"
+    // message.id defined: the element is wrapped in dom-if if="[[message.id]]"
     const detail: MessageAnchorTapDetail = {
       id: this.message!.id,
     };
@@ -472,7 +509,7 @@
     e.preventDefault();
     if (!this.message || !this.message.id || !this.changeNum) return;
     this._isDeletingChangeMsg = true;
-    this.$.restAPI
+    this.restApiService
       .deleteChangeCommitMessage(this.changeNum, this.message.id)
       .then(() => {
         this._isDeletingChangeMsg = false;
@@ -488,7 +525,7 @@
 
   @observe('projectName')
   _projectNameChanged(name: string) {
-    this.$.restAPI.getProjectConfig(name as RepoName).then(config => {
+    this.restApiService.getProjectConfig(name as RepoName).then(config => {
       this._projectConfig = config;
     });
   }
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index 96cc959..4a41316 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -39,11 +39,24 @@
     .contentContainer {
       padding: var(--spacing-m) var(--spacing-l);
     }
+    .expanded .contentContainer {
+      background-color: var(--background-color-secondary);
+    }
     .collapsed .contentContainer {
-      /* For expanded state we inherit the alternating background color
-           that is set in gr-messages-list. */
       background-color: var(--background-color-primary);
     }
+    div.serviceUser.expanded div.contentContainer {
+      background-color: var(
+        --background-color-service-user,
+        var(--background-color-secondary)
+      );
+    }
+    div.serviceUser.collapsed div.contentContainer {
+      background-color: var(
+        --background-color-service-user,
+        var(--background-color-primary)
+      );
+    }
     .name {
       font-weight: var(--font-weight-bold);
     }
@@ -96,7 +109,9 @@
       margin-right: var(--spacing-s);
     }
     .authorLabel {
-      width: 140px;
+      min-width: 130px;
+      --account-max-length: 120px;
+      margin-right: var(--spacing-s);
     }
     .expanded .author {
       cursor: pointer;
@@ -111,13 +126,17 @@
       right: var(--spacing-l);
       top: var(--spacing-m);
     }
-    .dateContainer .patchset {
+    .dateContainer gr-button {
       margin-right: var(--spacing-m);
       color: var(--deemphasized-text-color);
     }
     .dateContainer .patchset:before {
       content: 'Patchset ';
     }
+    .dateContainer .patchsetDiffButton {
+      margin-right: var(--spacing-m);
+      --padding: 0 var(--spacing-m);
+    }
     span.date {
       color: var(--deemphasized-text-color);
     }
@@ -151,15 +170,29 @@
     }
     .score.negative {
       background-color: var(--vote-color-disliked);
+      border: 1px solid var(--vote-outline-disliked);
+      line-height: calc(var(--line-height-normal) - 2px);
+      color: var(--chip-color);
     }
     .score.negative.min {
       background-color: var(--vote-color-rejected);
+      border: none;
+      padding-top: 1px;
+      padding-bottom: 1px;
+      color: var(--vote-text-color);
     }
     .score.positive {
       background-color: var(--vote-color-recommended);
+      border: 1px solid var(--vote-outline-recommended);
+      line-height: calc(var(--line-height-normal) - 2px);
+      color: var(--chip-color);
     }
     .score.positive.max {
       background-color: var(--vote-color-approved);
+      border: none;
+      padding-top: 1px;
+      padding-bottom: 1px;
+      color: var(--vote-text-color);
     }
     gr-account-label {
       --gr-account-label-text-style: {
@@ -182,7 +215,7 @@
       }
     }
   </style>
-  <div class$="[[_computeClass(_expanded)]]">
+  <div class$="[[_computeClass(_expanded, author)]]">
     <div class="contentContainer">
       <div class="author" on-click="_handleAuthorClick">
         <span hidden$="[[!showOnBehalfOf]]">
@@ -253,7 +286,7 @@
               change-num="[[changeNum]]"
               logged-in="[[_loggedIn]]"
               hide-toggle-buttons
-              on-thread-list-modified="_onThreadListModified"
+              show-comment-context
             >
             </gr-thread-list>
           </template>
@@ -269,15 +302,25 @@
                 items="[[update.reviewers]]"
                 as="reviewer"
               >
-                <gr-account-chip account="[[reviewer]]"> </gr-account-chip>
+                <gr-account-chip account="[[reviewer]]" change="[[change]]">
+                </gr-account-chip>
               </template>
             </div>
           </template>
         </div>
       </template>
       <span class="dateContainer">
+        <template is="dom-if" if="[[_showViewDiffButton(message)]]">
+          <gr-button
+            class="patchsetDiffButton"
+            on-click="_handleViewPatchsetDiff"
+            link
+          >
+            View Diff
+          </gr-button>
+        </template>
         <template is="dom-if" if="[[message._revision_number]]">
-          <span class="patchset">[[message._revision_number]]</span>
+          <span class="patchset">[[message._revision_number]] |</span>
         </template>
         <template is="dom-if" if="[[!message.id]]">
           <span class="date">
@@ -306,5 +349,4 @@
       </span>
     </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
deleted file mode 100644
index a705d45..0000000
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
+++ /dev/null
@@ -1,536 +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-message.js';
-
-const basicFixture = fixtureFromElement('gr-message');
-
-suite('gr-message tests', () => {
-  let element;
-
-  suite('when admin and logged in', () => {
-    setup(done => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getPreferences() { return Promise.resolve({}); },
-        getConfig() { return Promise.resolve({}); },
-        getIsAdmin() { return Promise.resolve(true); },
-        deleteChangeCommitMessage() { return Promise.resolve({}); },
-      });
-      element = basicFixture.instantiate();
-      flush(done);
-    });
-
-    test('reply event', done => {
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1,
-        expanded: true,
-      };
-
-      element.addEventListener('reply', e => {
-        assert.deepEqual(e.detail.message, element.message);
-        done();
-      });
-      flush();
-      assert.isFalse(
-          element.shadowRoot.querySelector('.replyActionContainer').hidden
-      );
-      MockInteractions.tap(element.shadowRoot.querySelector('.replyBtn'));
-    });
-
-    test('can see delete button', () => {
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1,
-        expanded: true,
-      };
-
-      flush();
-      assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').hidden);
-    });
-
-    test('delete change message', done => {
-      element.changeNum = 314159;
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1,
-        expanded: true,
-      };
-
-      element.addEventListener('change-message-deleted', e => {
-        assert.deepEqual(e.detail.message, element.message);
-        assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').disabled);
-        done();
-      });
-      flush();
-      MockInteractions.tap(element.shadowRoot.querySelector('.deleteBtn'));
-      assert.isTrue(element.shadowRoot.querySelector('.deleteBtn').disabled);
-    });
-
-    test('autogenerated prefix hiding', () => {
-      element.message = {
-        tag: 'autogenerated:gerrit:test',
-        updated: '2016-01-12 20:24:49.448000000',
-        expanded: false,
-      };
-
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
-
-      element.hideAutomated = true;
-
-      assert.isTrue(element.hidden);
-    });
-
-    test('reviewer message treated as autogenerated', () => {
-      element.message = {
-        tag: 'autogenerated:gerrit:test',
-        updated: '2016-01-12 20:24:49.448000000',
-        reviewer: {},
-        expanded: false,
-      };
-
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
-
-      element.hideAutomated = true;
-
-      assert.isTrue(element.hidden);
-    });
-
-    test('batch reviewer message treated as autogenerated', () => {
-      element.message = {
-        type: 'REVIEWER_UPDATE',
-        updated: '2016-01-12 20:24:49.448000000',
-        reviewer: {},
-        expanded: false,
-      };
-
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
-
-      element.hideAutomated = true;
-
-      assert.isTrue(element.hidden);
-    });
-
-    test('tag that is not autogenerated prefix does not hide', () => {
-      element.message = {
-        tag: 'something',
-        updated: '2016-01-12 20:24:49.448000000',
-        expanded: false,
-      };
-
-      assert.isFalse(element.isAutomated);
-      assert.isFalse(element.hidden);
-
-      element.hideAutomated = true;
-
-      assert.isFalse(element.hidden);
-    });
-
-    test('reply button hidden unless logged in', () => {
-      const message = {
-        message: 'Uploaded patch set 1.',
-        expanded: false,
-      };
-      assert.isFalse(element._computeShowReplyButton(message, false));
-      assert.isTrue(element._computeShowReplyButton(message, true));
-    });
-
-    test('_computeShowOnBehalfOf', () => {
-      const message = {
-        message: '...',
-        expanded: false,
-      };
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
-      message.author = {_account_id: 1115495};
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
-      message.real_author = {_account_id: 1115495};
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
-      message.real_author._account_id = 123456;
-      assert.isOk(element._computeShowOnBehalfOf(message));
-      message.updated_by = message.author;
-      delete message.author;
-      assert.isOk(element._computeShowOnBehalfOf(message));
-      delete message.updated_by;
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
-    });
-
-    ['Trybot-Ready', 'Tryjob-Request', 'Commit-Queue'].forEach(label => {
-      test(`${label} ignored for color voting`, () => {
-        element.message = {
-          author: {},
-          expanded: false,
-          message: `Patch Set 1: ${label}+1`,
-        };
-        assert.isNotOk(
-            element.root.querySelector('.negativeVote'));
-        assert.isNotOk(
-            element.root.querySelector('.positiveVote'));
-      });
-    });
-
-    test('clicking on date link fires event', () => {
-      element.message = {
-        type: 'REVIEWER_UPDATE',
-        updated: '2016-01-12 20:24:49.448000000',
-        reviewer: {},
-        id: '47c43261_55aa2c41',
-        expanded: false,
-      };
-      flush();
-      const stub = sinon.stub();
-      element.addEventListener('message-anchor-tap', stub);
-      const dateEl = element.shadowRoot
-          .querySelector('.date');
-      assert.ok(dateEl);
-      MockInteractions.tap(dateEl);
-
-      assert.isTrue(stub.called);
-      assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message.id});
-    });
-
-    suite('compute messages', () => {
-      test('empty', () => {
-        assert.equal(element._computeMessageContent('', '', true), '');
-        assert.equal(element._computeMessageContent('', '', false), '');
-      });
-
-      test('new patchset', () => {
-        const original = 'Uploaded patch set 1.';
-        const tag = 'autogenerated:gerrit:newPatchSet';
-        let actual = element._computeMessageContent(original, tag, true);
-        assert.equal(actual, element._computeMessageContentCollapsed(
-            original, tag, []));
-        assert.equal(actual, original);
-        actual = element._computeMessageContent(original, tag, false);
-        assert.equal(actual, original);
-      });
-
-      test('new patchset rebased', () => {
-        const original = 'Patch Set 27: Patch Set 26 was rebased';
-        const tag = 'autogenerated:gerrit:newPatchSet';
-        const expected = 'Patch Set 26 was rebased';
-        let actual = element._computeMessageContent(original, tag, true);
-        assert.equal(actual, expected);
-        assert.equal(actual, element._computeMessageContentCollapsed(
-            original, tag, []));
-        actual = element._computeMessageContent(original, tag, false);
-        assert.equal(actual, expected);
-      });
-
-      test('ready for review', () => {
-        const original = 'Patch Set 1:\n\nThis change is ready for review.';
-        const tag = undefined;
-        const expected = 'This change is ready for review.';
-        let actual = element._computeMessageContent(original, tag, true);
-        assert.equal(actual, expected);
-        assert.equal(actual, element._computeMessageContentCollapsed(
-            original, tag, []));
-        actual = element._computeMessageContent(original, tag, false);
-        assert.equal(actual, expected);
-      });
-
-      test('vote', () => {
-        const original = 'Patch Set 1: Code-Style+1';
-        const tag = undefined;
-        const expected = '';
-        let actual = element._computeMessageContent(original, tag, true);
-        assert.equal(actual, expected);
-        actual = element._computeMessageContent(original, tag, false);
-        assert.equal(actual, expected);
-      });
-
-      test('comments', () => {
-        const original = 'Patch Set 1:\n\n(3 comments)';
-        const tag = undefined;
-        const expected = '';
-        let actual = element._computeMessageContent(original, tag, true);
-        assert.equal(actual, expected);
-        actual = element._computeMessageContent(original, tag, false);
-        assert.equal(actual, expected);
-      });
-    });
-
-    test('votes', () => {
-      element.message = {
-        author: {},
-        expanded: false,
-        message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
-      };
-      element.labelExtremes = {
-        'Verified': {max: 1, min: -1},
-        'Code-Review': {max: 2, min: -2},
-        'Trybot-Label3': {max: 3, min: 0},
-      };
-      flush();
-      const scoreChips = element.root.querySelectorAll('.score');
-      assert.equal(scoreChips.length, 3);
-
-      assert.isTrue(scoreChips[0].classList.contains('positive'));
-      assert.isTrue(scoreChips[0].classList.contains('max'));
-
-      assert.isTrue(scoreChips[1].classList.contains('negative'));
-      assert.isTrue(scoreChips[1].classList.contains('min'));
-
-      assert.isTrue(scoreChips[2].classList.contains('positive'));
-      assert.isFalse(scoreChips[2].classList.contains('min'));
-    });
-
-    test('Uploaded patch set X', () => {
-      element.message = {
-        author: {},
-        expanded: false,
-        message: 'Uploaded patch set 1:' +
-         'Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
-      };
-      element.labelExtremes = {
-        'Verified': {max: 1, min: -1},
-        'Code-Review': {max: 2, min: -2},
-        'Trybot-Label3': {max: 3, min: 0},
-      };
-      flush();
-      const scoreChips = element.root.querySelectorAll('.score');
-      assert.equal(scoreChips.length, 3);
-
-      assert.isTrue(scoreChips[0].classList.contains('positive'));
-      assert.isTrue(scoreChips[0].classList.contains('max'));
-
-      assert.isTrue(scoreChips[1].classList.contains('negative'));
-      assert.isTrue(scoreChips[1].classList.contains('min'));
-
-      assert.isTrue(scoreChips[2].classList.contains('positive'));
-      assert.isFalse(scoreChips[2].classList.contains('min'));
-    });
-
-    test('removed votes', () => {
-      element.message = {
-        author: {},
-        expanded: false,
-        message: 'Patch Set 1: Verified+1 -Code-Review -Commit-Queue',
-      };
-      element.labelExtremes = {
-        'Verified': {max: 1, min: -1},
-        'Code-Review': {max: 2, min: -2},
-        'Commit-Queue': {max: 3, min: 0},
-      };
-      flush();
-      const scoreChips = element.root.querySelectorAll('.score');
-      assert.equal(scoreChips.length, 3);
-
-      assert.isTrue(scoreChips[1].classList.contains('removed'));
-      assert.isTrue(scoreChips[2].classList.contains('removed'));
-    });
-
-    test('false negative vote', () => {
-      element.message = {
-        author: {},
-        expanded: false,
-        message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
-      };
-      element.labelExtremes = {};
-      const scoreChips = element.root.querySelectorAll('.score');
-      assert.equal(scoreChips.length, 0);
-    });
-  });
-
-  suite('when not logged in', () => {
-    setup(done => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-        getPreferences() { return Promise.resolve({}); },
-        getConfig() { return Promise.resolve({}); },
-        getIsAdmin() { return Promise.resolve(false); },
-        deleteChangeCommitMessage() { return Promise.resolve({}); },
-      });
-      element = basicFixture.instantiate();
-      flush(done);
-    });
-
-    test('reply and delete button should be hidden', () => {
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1,
-        expanded: true,
-      };
-
-      flush();
-      assert.isTrue(
-          element.shadowRoot.querySelector('.replyActionContainer').hidden
-      );
-      assert.isTrue(
-          element.shadowRoot.querySelector('.deleteBtn').hidden
-      );
-    });
-  });
-
-  suite('patchset comment summary', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.message = {id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3'};
-    });
-
-    test('single patchset comment posted', () => {
-      const threads = [{
-        comments: [{
-          __path: '/PATCHSET_LEVEL',
-          change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3',
-          patch_set: 1,
-          id: 'e365b138_bed65caa',
-          updated: '2020-05-15 13:35:56.000000000',
-          message: 'testing the load',
-          unresolved: false,
-          path: '/PATCHSET_LEVEL',
-          collapsed: false,
-        }],
-        patchNum: 1,
-        path: '/PATCHSET_LEVEL',
-        rootId: 'e365b138_bed65caa',
-      }];
-      assert.equal(element._computeMessageContentCollapsed(
-          '', undefined, threads), 'testing the load');
-      assert.equal(element._computeMessageContent('', undefined, false), '');
-    });
-
-    test('single patchset comment with reply', () => {
-      const threads = [{
-        comments: [{
-          __path: '/PATCHSET_LEVEL',
-          patch_set: 1,
-          id: 'e365b138_bed65caa',
-          updated: '2020-05-15 13:35:56.000000000',
-          message: 'testing the load',
-          unresolved: false,
-          path: '/PATCHSET_LEVEL',
-          collapsed: false,
-        }, {
-          __path: '/PATCHSET_LEVEL',
-          change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3',
-          patch_set: 1,
-          id: 'd6efcc85_4cbbb6f4',
-          in_reply_to: 'e365b138_bed65caa',
-          updated: '2020-05-15 16:55:28.000000000',
-          message: 'n',
-          unresolved: false,
-          path: '/PATCHSET_LEVEL',
-          __draft: true,
-          collapsed: true,
-        }],
-        patchNum: 1,
-        path: '/PATCHSET_LEVEL',
-        rootId: 'e365b138_bed65caa',
-      }];
-      assert.equal(element._computeMessageContentCollapsed(
-          '', undefined, threads), 'n');
-      assert.equal(element._computeMessageContent('', undefined, false), '');
-    });
-  });
-
-  suite('when logged in but not admin', () => {
-    setup(done => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getConfig() { return Promise.resolve({}); },
-        getIsAdmin() { return Promise.resolve(false); },
-        deleteChangeCommitMessage() { return Promise.resolve({}); },
-      });
-      element = basicFixture.instantiate();
-      flush(done);
-    });
-
-    test('can see reply but not delete button', () => {
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1,
-        expanded: true,
-      };
-
-      flush();
-      assert.isFalse(
-          element.shadowRoot.querySelector('.replyActionContainer').hidden
-      );
-      assert.isTrue(
-          element.shadowRoot.querySelector('.deleteBtn').hidden
-      );
-    });
-
-    test('reply button shown when message is updated', () => {
-      element.message = undefined;
-      flush();
-      let replyEl = element.shadowRoot.querySelector('.replyActionContainer');
-      // We don't even expect the button to show up in the DOM when the message
-      // is undefined.
-      assert.isNotOk(replyEl);
-
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'not empty',
-        _revision_number: 1,
-        expanded: true,
-      };
-      flush();
-      replyEl = element.shadowRoot.querySelector('.replyActionContainer');
-      assert.isOk(replyEl);
-      assert.isFalse(replyEl.hidden);
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..b8f3c73
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -0,0 +1,676 @@
+/**
+ * @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-message';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  createChange,
+  createChangeMessage,
+  createComment,
+  createRevisions,
+} from '../../../test/test-data-generators';
+import {
+  query,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {GrMessage} from './gr-message';
+import {
+  AccountId,
+  BasePatchSetNum,
+  ChangeMessageId,
+  EmailAddress,
+  NumericChangeId,
+  PatchSetNum,
+  ReviewInputTag,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {
+  ChangeMessageDeletedEventDetail,
+  ReplyEventDetail,
+} from '../../../types/events';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {CommentSide} from '../../../constants/constants';
+import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+
+const basicFixture = fixtureFromElement('gr-message');
+
+suite('gr-message tests', () => {
+  let element: GrMessage;
+
+  suite('when admin and logged in', () => {
+    setup(done => {
+      stubRestApi('getIsAdmin').returns(Promise.resolve(true));
+      element = basicFixture.instantiate();
+      flush(done);
+    });
+
+    test('reply event', done => {
+      element.message = {
+        ...createChangeMessage(),
+        id: '47c43261_55aa2c41' as ChangeMessageId,
+        author: {
+          _account_id: 1115495 as AccountId,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org' as EmailAddress,
+        },
+        date: '2016-01-12 20:24:49.448000000' as Timestamp,
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1 as PatchSetNum,
+        expanded: true,
+      };
+
+      element.addEventListener('reply', (e: CustomEvent<ReplyEventDetail>) => {
+        assert.deepEqual(e.detail.message, element.message);
+        done();
+      });
+      flush();
+      assert.isFalse(
+        queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
+      );
+      tap(queryAndAssert(element, '.replyBtn'));
+    });
+
+    test('can see delete button', () => {
+      element.message = {
+        ...createChangeMessage(),
+        id: '47c43261_55aa2c41' as ChangeMessageId,
+        author: {
+          _account_id: 1115495 as AccountId,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org' as EmailAddress,
+        },
+        date: '2016-01-12 20:24:49.448000000' as Timestamp,
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1 as PatchSetNum,
+        expanded: true,
+      };
+
+      flush();
+      assert.isFalse(queryAndAssert<HTMLElement>(element, '.deleteBtn').hidden);
+    });
+
+    test('delete change message', done => {
+      element.changeNum = 314159 as NumericChangeId;
+      element.message = {
+        ...createChangeMessage(),
+        id: '47c43261_55aa2c41' as ChangeMessageId,
+        author: {
+          _account_id: 1115495 as AccountId,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org' as EmailAddress,
+        },
+        date: '2016-01-12 20:24:49.448000000' as Timestamp,
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1 as PatchSetNum,
+        expanded: true,
+      };
+
+      element.addEventListener(
+        'change-message-deleted',
+        (e: CustomEvent<ChangeMessageDeletedEventDetail>) => {
+          assert.deepEqual(e.detail.message, element.message);
+          assert.isFalse(
+            (queryAndAssert(element, '.deleteBtn') as GrButton).disabled
+          );
+          done();
+        }
+      );
+      flush();
+      tap(queryAndAssert(element, '.deleteBtn'));
+      assert.isTrue(
+        (queryAndAssert(element, '.deleteBtn') as GrButton).disabled
+      );
+    });
+
+    test('autogenerated prefix hiding', () => {
+      element.message = {
+        ...createChangeMessage(),
+        tag: 'autogenerated:gerrit:test' as ReviewInputTag,
+        expanded: false,
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
+    test('reviewer message treated as autogenerated', () => {
+      element.message = {
+        ...createChangeMessage(),
+        tag: 'autogenerated:gerrit:test' as ReviewInputTag,
+        reviewer: {},
+        expanded: false,
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
+    test('batch reviewer message treated as autogenerated', () => {
+      element.message = {
+        ...createChangeMessage(),
+        type: 'REVIEWER_UPDATE',
+        reviewer: {},
+        expanded: false,
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
+    test('tag that is not autogenerated prefix does not hide', () => {
+      element.message = {
+        ...createChangeMessage(),
+        tag: 'something' as ReviewInputTag,
+        expanded: false,
+      };
+
+      assert.isFalse(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isFalse(element.hidden);
+    });
+
+    test('reply button hidden unless logged in', () => {
+      const message = {
+        ...createChangeMessage(),
+        message: 'Uploaded patch set 1.',
+        expanded: false,
+      };
+      assert.isFalse(element._computeShowReplyButton(message, false));
+      assert.isTrue(element._computeShowReplyButton(message, true));
+    });
+
+    test('_computeShowOnBehalfOf', () => {
+      const message = {
+        ...createChangeMessage(),
+        message: '...',
+        expanded: false,
+      };
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.author = {_account_id: 1115495 as AccountId};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author = {_account_id: 1115495 as AccountId};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author._account_id = 123456 as AccountId;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      message.updated_by = message.author;
+      delete message.author;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      delete message.updated_by;
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+    });
+
+    ['Trybot-Ready', 'Tryjob-Request', 'Commit-Queue'].forEach(label => {
+      test(`${label} ignored for color voting`, () => {
+        element.message = {
+          ...createChangeMessage(),
+          author: {},
+          expanded: false,
+          message: `Patch Set 1: ${label}+1`,
+        };
+        assert.isNotOk(query(element, '.negativeVote'));
+        assert.isNotOk(query(element, '.positiveVote'));
+      });
+    });
+
+    test('clicking on date link fires event', () => {
+      element.message = {
+        ...createChangeMessage(),
+        type: 'REVIEWER_UPDATE',
+        reviewer: {},
+        id: '47c43261_55aa2c41' as ChangeMessageId,
+        expanded: false,
+      };
+      flush();
+      const stub = sinon.stub();
+      element.addEventListener('message-anchor-tap', stub);
+      const dateEl = queryAndAssert(element, '.date');
+      assert.ok(dateEl);
+      tap(dateEl);
+
+      assert.isTrue(stub.called);
+      assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message.id});
+    });
+
+    suite('uploaded patchset X message navigates to X - 1 vs  X', () => {
+      let navStub: SinonStubbedMember<typeof GerritNav.navigateToChange>;
+      setup(() => {
+        element.change = {...createChange(), revisions: createRevisions(4)};
+        navStub = sinon.stub(GerritNav, 'navigateToChange');
+      });
+
+      test('Patchset 1 navigates to Base', () => {
+        element.message = {
+          ...createChangeMessage(),
+          message: 'Uploaded patch set 1.',
+        };
+        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        assert.isTrue(
+          navStub.calledWithExactly(
+            element.change!,
+            1 as PatchSetNum,
+            'PARENT' as BasePatchSetNum
+          )
+        );
+      });
+
+      test('Patchset X navigates to X vs X - 1', () => {
+        element.message = {
+          ...createChangeMessage(),
+          message: 'Uploaded patch set 2.',
+        };
+        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        assert.isTrue(
+          navStub.calledWithExactly(
+            element.change!,
+            2 as PatchSetNum,
+            1 as BasePatchSetNum
+          )
+        );
+
+        element.message = {
+          ...createChangeMessage(),
+          message: 'Uploaded patch set 200.',
+        };
+        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        assert.isTrue(
+          navStub.calledWithExactly(
+            element.change!,
+            200 as PatchSetNum,
+            199 as BasePatchSetNum
+          )
+        );
+      });
+
+      test('Commit message updated', () => {
+        element.message = {
+          ...createChangeMessage(),
+          message: 'Commit message updated.',
+        };
+        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        assert.isTrue(
+          navStub.calledWithExactly(
+            element.change!,
+            4 as PatchSetNum,
+            3 as BasePatchSetNum
+          )
+        );
+      });
+
+      test('Merged patchset change message', () => {
+        element.message = {
+          ...createChangeMessage(),
+          message: 'abcd↵3 is the latest approved patch-set.↵abc',
+        };
+        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        assert.isTrue(
+          navStub.calledWithExactly(
+            element.change!,
+            4 as PatchSetNum,
+            3 as BasePatchSetNum
+          )
+        );
+      });
+    });
+
+    suite('compute messages', () => {
+      test('empty', () => {
+        assert.equal(
+          element._computeMessageContent(true, '', '' as ReviewInputTag),
+          ''
+        );
+        assert.equal(
+          element._computeMessageContent(false, '', '' as ReviewInputTag),
+          ''
+        );
+      });
+
+      test('new patchset', () => {
+        const original = 'Uploaded patch set 1.';
+        const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
+        let actual = element._computeMessageContent(true, original, tag);
+        assert.equal(
+          actual,
+          element._computeMessageContentCollapsed(original, tag, [])
+        );
+        assert.equal(actual, original);
+        actual = element._computeMessageContent(false, original, tag);
+        assert.equal(actual, original);
+      });
+
+      test('new patchset rebased', () => {
+        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);
+        assert.equal(actual, expected);
+        assert.equal(
+          actual,
+          element._computeMessageContentCollapsed(original, tag, [])
+        );
+        actual = element._computeMessageContent(false, original, tag);
+        assert.equal(actual, expected);
+      });
+
+      test('ready for review', () => {
+        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);
+        assert.equal(actual, expected);
+        assert.equal(
+          actual,
+          element._computeMessageContentCollapsed(original, tag, [])
+        );
+        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);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(false, original, tag);
+        assert.equal(actual, expected);
+      });
+
+      test('comments', () => {
+        const original = 'Patch Set 1:\n\n(3 comments)';
+        const tag = undefined;
+        const expected = '';
+        let actual = element._computeMessageContent(true, original, tag);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(false, original, tag);
+        assert.equal(actual, expected);
+      });
+    });
+
+    test('votes', () => {
+      element.message = {
+        ...createChangeMessage(),
+        author: {},
+        expanded: false,
+        message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
+      };
+      element.labelExtremes = {
+        Verified: {max: 1, min: -1},
+        'Code-Review': {max: 2, min: -2},
+        'Trybot-Label3': {max: 3, min: 0},
+      };
+      flush();
+      const scoreChips = queryAll(element, '.score');
+      assert.equal(scoreChips.length, 3);
+
+      assert.isTrue(scoreChips[0].classList.contains('positive'));
+      assert.isTrue(scoreChips[0].classList.contains('max'));
+
+      assert.isTrue(scoreChips[1].classList.contains('negative'));
+      assert.isTrue(scoreChips[1].classList.contains('min'));
+
+      assert.isTrue(scoreChips[2].classList.contains('positive'));
+      assert.isFalse(scoreChips[2].classList.contains('min'));
+    });
+
+    test('Uploaded patch set X', () => {
+      element.message = {
+        ...createChangeMessage(),
+        author: {},
+        expanded: false,
+        message:
+          'Uploaded patch set 1:' +
+          'Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
+      };
+      element.labelExtremes = {
+        Verified: {max: 1, min: -1},
+        'Code-Review': {max: 2, min: -2},
+        'Trybot-Label3': {max: 3, min: 0},
+      };
+      flush();
+      const scoreChips = queryAll(element, '.score');
+      assert.equal(scoreChips.length, 3);
+
+      assert.isTrue(scoreChips[0].classList.contains('positive'));
+      assert.isTrue(scoreChips[0].classList.contains('max'));
+
+      assert.isTrue(scoreChips[1].classList.contains('negative'));
+      assert.isTrue(scoreChips[1].classList.contains('min'));
+
+      assert.isTrue(scoreChips[2].classList.contains('positive'));
+      assert.isFalse(scoreChips[2].classList.contains('min'));
+    });
+
+    test('removed votes', () => {
+      element.message = {
+        ...createChangeMessage(),
+        author: {},
+        expanded: false,
+        message: 'Patch Set 1: Verified+1 -Code-Review -Commit-Queue',
+      };
+      element.labelExtremes = {
+        Verified: {max: 1, min: -1},
+        'Code-Review': {max: 2, min: -2},
+        'Commit-Queue': {max: 3, min: 0},
+      };
+      flush();
+      const scoreChips = queryAll(element, '.score');
+      assert.equal(scoreChips.length, 3);
+
+      assert.isTrue(scoreChips[1].classList.contains('removed'));
+      assert.isTrue(scoreChips[2].classList.contains('removed'));
+    });
+
+    test('false negative vote', () => {
+      element.message = {
+        ...createChangeMessage(),
+        author: {},
+        expanded: false,
+        message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
+      };
+      element.labelExtremes = {};
+      const scoreChips = element.root!.querySelectorAll('.score');
+      assert.equal(scoreChips.length, 0);
+    });
+  });
+
+  suite('when not logged in', () => {
+    setup(done => {
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      stubRestApi('getIsAdmin').returns(Promise.resolve(false));
+      element = basicFixture.instantiate();
+      flush(done);
+    });
+
+    test('reply and delete button should be hidden', () => {
+      element.message = {
+        ...createChangeMessage(),
+        id: '47c43261_55aa2c41' as ChangeMessageId,
+        author: {
+          _account_id: 1115495 as AccountId,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org' as EmailAddress,
+        },
+        date: '2016-01-12 20:24:49.448000000' as Timestamp,
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1 as PatchSetNum,
+        expanded: true,
+      };
+
+      flush();
+      assert.isTrue(
+        queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
+      );
+      assert.isTrue(queryAndAssert<HTMLElement>(element, '.deleteBtn').hidden);
+    });
+  });
+
+  suite('patchset comment summary', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.message = {
+        ...createChangeMessage(),
+        id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3' as ChangeMessageId,
+      };
+    });
+
+    test('single patchset comment posted', () => {
+      const threads = [
+        {
+          comments: [
+            {
+              ...createComment(),
+              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,
+              message: 'testing the load',
+              unresolved: false,
+              path: '/PATCHSET_LEVEL',
+              collapsed: false,
+            },
+          ],
+          patchNum: 1 as PatchSetNum,
+          path: '/PATCHSET_LEVEL',
+          rootId: 'e365b138_bed65caa' as UrlEncodedCommentId,
+          commentSide: CommentSide.REVISION,
+        },
+      ];
+      assert.equal(
+        element._computeMessageContentCollapsed('', undefined, threads),
+        'testing the load'
+      );
+      assert.equal(element._computeMessageContent(false, '', undefined), '');
+    });
+
+    test('single patchset comment with reply', () => {
+      const threads = [
+        {
+          comments: [
+            {
+              ...createComment(),
+              patch_set: 1 as PatchSetNum,
+              id: 'e365b138_bed65caa' as UrlEncodedCommentId,
+              updated: '2020-05-15 13:35:56.000000000' as Timestamp,
+              message: 'testing the load',
+              unresolved: false,
+              path: '/PATCHSET_LEVEL',
+              collapsed: false,
+            },
+            {
+              change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3',
+              patch_set: 1 as PatchSetNum,
+              id: 'd6efcc85_4cbbb6f4' as UrlEncodedCommentId,
+              in_reply_to: 'e365b138_bed65caa' as UrlEncodedCommentId,
+              updated: '2020-05-15 16:55:28.000000000' as Timestamp,
+              message: 'n',
+              unresolved: false,
+              path: '/PATCHSET_LEVEL',
+              __draft: true,
+              collapsed: true,
+            },
+          ],
+          patchNum: 1 as PatchSetNum,
+          path: '/PATCHSET_LEVEL',
+          rootId: 'e365b138_bed65caa' as UrlEncodedCommentId,
+          commentSide: CommentSide.REVISION,
+        },
+      ];
+      assert.equal(
+        element._computeMessageContentCollapsed('', undefined, threads),
+        'n'
+      );
+      assert.equal(element._computeMessageContent(false, '', undefined), '');
+    });
+  });
+
+  suite('when logged in but not admin', () => {
+    setup(async () => {
+      stubRestApi('getIsAdmin').returns(Promise.resolve(false));
+      element = basicFixture.instantiate();
+      await flush();
+    });
+
+    test('can see reply but not delete button', () => {
+      element.message = {
+        ...createChangeMessage(),
+        id: '47c43261_55aa2c41' as ChangeMessageId,
+        author: {
+          _account_id: 1115495 as AccountId,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org' as EmailAddress,
+        },
+        date: '2016-01-12 20:24:49.448000000' as Timestamp,
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1 as PatchSetNum,
+        expanded: true,
+      };
+
+      flush();
+      assert.isFalse(
+        queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
+      );
+      assert.isTrue(queryAndAssert<HTMLElement>(element, '.deleteBtn').hidden);
+    });
+
+    test('reply button shown when message is updated', () => {
+      element.message = undefined;
+      flush();
+      let replyEl = query(element, '.replyActionContainer');
+      // We don't even expect the button to show up in the DOM when the message
+      // is undefined.
+      assert.isNotOk(replyEl);
+
+      element.message = {
+        ...createChangeMessage(),
+        id: '47c43261_55aa2c41' as ChangeMessageId,
+        author: {
+          _account_id: 1115495 as AccountId,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org' as EmailAddress,
+        },
+        date: '2016-01-12 20:24:49.448000000' as Timestamp,
+        message: 'not empty',
+        _revision_number: 1 as PatchSetNum,
+        expanded: true,
+      };
+      flush();
+      replyEl = queryAndAssert(element, '.replyActionContainer');
+      assert.isOk(replyEl);
+      assert.isFalse((replyEl as HTMLElement).hidden);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 8557c10..6be0c07 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
@@ -19,8 +19,6 @@
 import '../../shared/gr-icons/gr-icons';
 import '../gr-message/gr-message';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-messages-list_html';
 import {
@@ -48,9 +46,9 @@
 import {CommentThread, isRobot} from '../../../utils/comment-util';
 import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {FormattedReviewerUpdateInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {DomRepeat} from '@polymer/polymer/lib/elements/dom-repeat';
 import {getVotingRange} from '../../../utils/label-util';
+import {FormattedReviewerUpdateInfo} from '../../../types/types';
 
 /**
  * The content of the enum is also used in the UI for the button text.
@@ -118,7 +116,7 @@
 
 /**
  * If messages have the same tag, then that influences grouping and whether
- * a message is initally hidden or not, see isImportant(). So we are applying
+ * a message is initially hidden or not, see isImportant(). So we are applying
  * some "magic" rules here in order to hide exactly the right messages.
  *
  * 1. If a message does not have a tag, but is associated with robot comments,
@@ -213,9 +211,7 @@
 }
 
 @customElement('gr-messages-list')
-export class GrMessagesList extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrMessagesList extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -263,7 +259,7 @@
   _combinedMessages: CombinedMessage[] = [];
 
   @property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
-  _labelExtremes: {[lableName: string]: VotingRangeInfo} = {};
+  _labelExtremes: {[labelName: string]: VotingRangeInfo} = {};
 
   private readonly reporting = appContext.reportingService;
 
@@ -466,7 +462,7 @@
       LabelNameToInfoMap
     >
   ) {
-    const extremes: {[lableName: string]: VotingRangeInfo} = {};
+    const extremes: {[labelName: string]: VotingRangeInfo} = {};
     const labels = labelRecord.base;
     if (!labels) {
       return extremes;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
index d3a72072..8fa8eab 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
@@ -24,7 +24,6 @@
     }
     .header {
       align-items: center;
-      border-top: 1px solid var(--border-color);
       border-bottom: 1px solid var(--border-color);
       display: flex;
       justify-content: space-between;
@@ -51,9 +50,6 @@
     gr-message:not(:last-of-type) {
       border-bottom: 1px solid var(--border-color);
     }
-    gr-message {
-      background-color: var(--background-color-secondary);
-    }
   </style>
   <div class="header">
     <div id="showAllActivityToggleContainer" class="container">
@@ -66,7 +62,7 @@
           checked="{{_showAllActivity}}"
           aria-labelledby="showAllEntriesLabel"
           role="switch"
-          on-tap="_onTapShowAllActivityToggle"
+          on-click="_onTapShowAllActivityToggle"
         ></paper-toggle-button>
         <div id="showAllEntriesLabel" aria-hidden="true">
           <span>Show all entries</span>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
index c27f9fd..fd45eec 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
@@ -22,6 +22,7 @@
 import {TEST_ONLY} from './gr-messages-list.js';
 import {MessageTag} from '../../../constants/constants.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 createCommentApiMockWithTemplateElement(
     'gr-messages-list-comment-mock-api', html`
@@ -132,13 +133,10 @@
 
   suite('basic tests', () => {
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getLoggedIn() { return Promise.resolve(false); },
-        getDiffComments() { return Promise.resolve(comments); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-      });
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      stubRestApi('getDiffComments').returns(Promise.resolve(comments));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
 
       messages = generateRandomMessages(3);
       // Element must be wrapped in an element with direct access to the
@@ -441,13 +439,10 @@
     let commentApiWrapper;
 
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getLoggedIn() { return Promise.resolve(false); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-      });
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
 
       messages = [
         randomMessage(),
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
new file mode 100644
index 0000000..49c2486
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
@@ -0,0 +1,186 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from 'lit-html';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {customElement, property, css} from 'lit-element';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {
+  ChangeInfo,
+  RelatedChangeAndCommitInfo,
+  CommitId,
+} from '../../../types/common';
+import {ChangeStatus} from '../../../constants/constants';
+import {isChangeInfo} from '../../../utils/change-util';
+
+@customElement('gr-related-change')
+export class GrRelatedChange extends GrLitElement {
+  @property()
+  change?: ChangeInfo | RelatedChangeAndCommitInfo;
+
+  @property()
+  href?: string;
+
+  @property()
+  showSubmittableCheck = false;
+
+  @property()
+  showChangeStatus = false;
+
+  /*
+   * Needed for calculation if change is direct or indirect ancestor/descendant
+   * to current change.
+   */
+  @property()
+  connectedRevisions?: CommitId[];
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        a {
+          display: block;
+        }
+        .changeContainer,
+        a {
+          max-width: 100%;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        .changeContainer {
+          display: flex;
+        }
+        .strikethrough {
+          color: var(--deemphasized-text-color);
+          text-decoration: line-through;
+        }
+        .status {
+          color: var(--deemphasized-text-color);
+          font-weight: var(--font-weight-bold);
+          margin-left: var(--spacing-xs);
+        }
+        .notCurrent {
+          color: var(--warning-foreground);
+        }
+        .indirectAncestor {
+          color: var(--indirect-ancestor-text-color);
+        }
+        .submittableCheck {
+          padding-left: var(--spacing-s);
+          color: var(--positive-green-text-color);
+          display: none;
+        }
+        .submittableCheck.submittable {
+          display: inline;
+        }
+        .hidden,
+        .mobile {
+          display: none;
+        }
+        .submittableCheck {
+          padding-left: var(--spacing-s);
+          color: var(--positive-green-text-color);
+          display: none;
+        }
+        .submittableCheck.submittable {
+          display: inline;
+        }
+      `,
+    ];
+  }
+
+  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>
+        ${this.showSubmittableCheck
+          ? html`<span
+              tabindex="-1"
+              title="Submittable"
+              class="submittableCheck ${linkClass}"
+              role="img"
+              aria-label="Submittable"
+              >✓</span
+            >`
+          : ''}
+        ${this.showChangeStatus && !isChangeInfo(change)
+          ? html`<span class="${this._computeChangeStatusClass(change)}">
+              (${this._computeChangeStatus(change)})
+            </span>`
+          : ''}
+      </div>
+    `;
+  }
+
+  _computeLinkClass(change: ChangeInfo | RelatedChangeAndCommitInfo) {
+    const statuses = [];
+    if (change.status === ChangeStatus.ABANDONED) {
+      statuses.push('strikethrough');
+    }
+    if (change.submittable) {
+      statuses.push('submittable');
+    }
+    return statuses.join(' ');
+  }
+
+  _computeChangeStatusClass(change: RelatedChangeAndCommitInfo) {
+    const classes = ['status'];
+    if (change._revision_number !== change._current_revision_number) {
+      classes.push('notCurrent');
+    } else if (this._isIndirectAncestor(change)) {
+      classes.push('indirectAncestor');
+    } else if (change.submittable) {
+      classes.push('submittable');
+    } else if (change.status === ChangeStatus.NEW) {
+      classes.push('hidden');
+    }
+    return classes.join(' ');
+  }
+
+  _computeChangeStatus(change: RelatedChangeAndCommitInfo) {
+    switch (change.status) {
+      case ChangeStatus.MERGED:
+        return 'Merged';
+      case ChangeStatus.ABANDONED:
+        return 'Abandoned';
+    }
+    if (change._revision_number !== change._current_revision_number) {
+      return 'Not current';
+    } else if (this._isIndirectAncestor(change)) {
+      return 'Indirect ancestor';
+    } else if (change.submittable) {
+      return 'Submittable';
+    }
+    return '';
+  }
+
+  _isIndirectAncestor(change: RelatedChangeAndCommitInfo) {
+    return (
+      this.connectedRevisions &&
+      !this.connectedRevisions.includes(change.commit.commit)
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-related-change': GrRelatedChange;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index f8a4af2..8d5bc33 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
@@ -1,6 +1,6 @@
 /**
  * @license
- * 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.
@@ -14,223 +14,547 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import '../../../styles/shared-styles';
+import {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 {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-related-changes-list_html';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {ChangeStatus} from '../../../constants/constants';
-import {patchNumEquals} from '../../../utils/patch-set-util';
-import {changeIsOpen} from '../../../utils/change-util';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {classMap} from 'lit-html/directives/class-map';
+import {GrLitElement} from '../../lit/gr-lit-element';
 import {
-  ChangeId,
+  customElement,
+  property,
+  css,
+  internalProperty,
+  TemplateResult,
+} from 'lit-element';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {
+  SubmittedTogetherInfo,
   ChangeInfo,
-  CommitId,
-  NumericChangeId,
-  PatchSetNum,
   RelatedChangeAndCommitInfo,
   RelatedChangesInfo,
-  RepoName,
-  SubmittedTogetherInfo,
+  PatchSetNum,
+  CommitId,
 } from '../../../types/common';
-import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {appContext} from '../../../services/app-context';
+import {ParsedChangeInfo} from '../../../types/types';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {pluralize} from '../../../utils/string-util';
+import {
+  changeIsOpen,
+  getRevisionKey,
+  isChangeInfo,
+} from '../../../utils/change-util';
 
-export interface GrRelatedChangesList {
-  $: {
-    restAPI: RestApiService & Element;
-  };
+/** What is the maximum number of shown changes in collapsed list? */
+const DEFALT_NUM_CHANGES_WHEN_COLLAPSED = 3;
+
+export interface ChangeMarkersInList {
+  showCurrentChangeArrow: boolean;
+  showWhenCollapsed: boolean;
+  showTopArrow: boolean;
+  showBottomArrow: boolean;
 }
 
-function getEmptySubmitTogetherInfo(): SubmittedTogetherInfo {
-  return {changes: [], non_visible_changes: 0};
-}
-
-function isChangeInfo(
-  x: ChangeInfo | RelatedChangeAndCommitInfo
-): x is ChangeInfo {
-  return (x as ChangeInfo)._number !== undefined;
+export enum Section {
+  RELATED_CHANGES = 'related changes',
+  SUBMITTED_TOGETHER = 'submitted together',
+  SAME_TOPIC = 'same topic',
+  MERGE_CONFLICTS = 'merge conflicts',
+  CHERRY_PICKS = 'cherry picks',
 }
 
 @customElement('gr-related-changes-list')
-export class GrRelatedChangesList extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when a new section is loaded so that the change view can determine
-   * a show more button is needed, sometimes before all the sections finish
-   * loading.
-   *
-   * @event new-section-loaded
-   */
-
-  @property({type: Object})
+export class GrRelatedChangesList extends GrLitElement {
+  @property()
   change?: ParsedChangeInfo;
 
-  @property({type: Boolean, notify: true})
-  hasParent = false;
-
   @property({type: String})
   patchNum?: PatchSetNum;
 
-  @property({type: Boolean, reflectToAttribute: true})
-  hidden = false;
-
-  @property({type: Boolean, notify: true})
-  loading?: boolean;
-
-  @property({type: Boolean})
+  @property()
   mergeable?: boolean;
 
-  @property({
-    type: Array,
-    computed:
-      '_computeConnectedRevisions(change, patchNum, ' +
-      '_relatedResponse.changes)',
-  })
-  _connectedRevisions?: CommitId[];
+  @internalProperty()
+  submittedTogether?: SubmittedTogetherInfo = {
+    changes: [],
+    non_visible_changes: 0,
+  };
 
-  @property({type: Object})
-  _relatedResponse: RelatedChangesInfo = {changes: []};
+  @internalProperty()
+  relatedChanges: RelatedChangeAndCommitInfo[] = [];
 
-  @property({type: Object})
-  _submittedTogether?: SubmittedTogetherInfo = getEmptySubmitTogetherInfo();
+  @internalProperty()
+  conflictingChanges: ChangeInfo[] = [];
 
-  @property({type: Array})
-  _conflicts: ChangeInfo[] = [];
+  @internalProperty()
+  cherryPickChanges: ChangeInfo[] = [];
 
-  @property({type: Array})
-  _cherryPicks: ChangeInfo[] = [];
+  @internalProperty()
+  sameTopicChanges: ChangeInfo[] = [];
 
-  @property({type: Array})
-  _sameTopic?: ChangeInfo[] = [];
+  private readonly restApiService = appContext.restApiService;
 
-  clear() {
-    this.loading = true;
-    this.hidden = true;
-
-    this._relatedResponse = {changes: []};
-    this._submittedTogether = getEmptySubmitTogetherInfo();
-    this._conflicts = [];
-    this._cherryPicks = [];
-    this._sameTopic = [];
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        .note {
+          color: var(--error-text-color);
+          margin-left: 1.2em;
+        }
+        section {
+          margin-bottom: var(--spacing-l);
+        }
+        gr-related-change {
+          display: flex;
+        }
+        .marker {
+          position: absolute;
+          margin-left: calc(-1 * var(--spacing-s));
+        }
+        .arrowToCurrentChange {
+          position: absolute;
+        }
+      `,
+    ];
   }
 
-  reload() {
-    if (!this.change || !this.patchNum) {
-      return Promise.resolve();
+  render() {
+    const sectionSize = this.sectionSizeFactory(
+      this.relatedChanges.length,
+      this.submittedTogether?.changes.length || 0,
+      this.sameTopicChanges.length,
+      this.conflictingChanges.length,
+      this.cherryPickChanges.length
+    );
+    const relatedChangesMarkersPredicate = this.markersPredicateFactory(
+      this.relatedChanges.length,
+      this.relatedChanges.findIndex(relatedChange =>
+        this._changesEqual(relatedChange, this.change)
+      ),
+      sectionSize(Section.RELATED_CHANGES)
+    );
+    const connectedRevisions = this._computeConnectedRevisions(
+      this.change,
+      this.patchNum,
+      this.relatedChanges
+    );
+    let firstNonEmptySectionFound = false;
+    let isFirstNonEmpty =
+      !firstNonEmptySectionFound && !!this.relatedChanges.length;
+    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
+    const relatedChangeSection = html` <section
+      id="relatedChanges"
+      ?hidden=${!this.relatedChanges.length}
+    >
+      <gr-related-collapse
+        title="Relation chain"
+        class="${classMap({first: isFirstNonEmpty})}"
+        .length=${this.relatedChanges.length}
+        .numChangesWhenCollapsed=${sectionSize(Section.RELATED_CHANGES)}
+      >
+        ${this.relatedChanges.map(
+          (change, index) =>
+            html`${this.renderMarkers(
+                relatedChangesMarkersPredicate(index)
+              )}<gr-related-change
+                class="${classMap({
+                  ['show-when-collapsed']: relatedChangesMarkersPredicate(index)
+                    .showWhenCollapsed,
+                })}"
+                .change="${change}"
+                .connectedRevisions="${connectedRevisions}"
+                .href="${change?._change_number
+                  ? GerritNav.getUrlForChangeById(
+                      change._change_number,
+                      change.project,
+                      change._revision_number as PatchSetNum
+                    )
+                  : ''}"
+                .showChangeStatus=${true}
+                >${change.commit.subject}</gr-related-change
+              >`
+        )}
+      </gr-related-collapse>
+    </section>`;
+
+    const submittedTogetherChanges = this.submittedTogether?.changes ?? [];
+    const countNonVisibleChanges =
+      this.submittedTogether?.non_visible_changes ?? 0;
+    const submittedTogetherMarkersPredicate = this.markersPredicateFactory(
+      submittedTogetherChanges.length,
+      submittedTogetherChanges.findIndex(relatedChange =>
+        this._changesEqual(relatedChange, this.change)
+      ),
+      sectionSize(Section.SUBMITTED_TOGETHER)
+    );
+    isFirstNonEmpty =
+      !firstNonEmptySectionFound &&
+      (!!submittedTogetherChanges?.length ||
+        !!this.submittedTogether?.non_visible_changes);
+    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
+    const submittedTogetherSection = html`<section
+      id="submittedTogether"
+      ?hidden=${!submittedTogetherChanges?.length &&
+      !this.submittedTogether?.non_visible_changes}
+    >
+      <gr-related-collapse
+        title="Submitted together"
+        class="${classMap({first: isFirstNonEmpty})}"
+        .length=${submittedTogetherChanges.length}
+        .numChangesWhenCollapsed=${sectionSize(Section.SUBMITTED_TOGETHER)}
+      >
+        ${submittedTogetherChanges.map(
+          (change, index) =>
+            html`${this.renderMarkers(
+                submittedTogetherMarkersPredicate(index)
+              )}<gr-related-change
+                class="${classMap({
+                  ['show-when-collapsed']: submittedTogetherMarkersPredicate(
+                    index
+                  ).showWhenCollapsed,
+                })}"
+                .change="${change}"
+                .href="${GerritNav.getUrlForChangeById(
+                  change._number,
+                  change.project
+                )}"
+                .showSubmittableCheck=${true}
+                >${change.project}: ${change.branch}:
+                ${change.subject}</gr-related-change
+              >`
+        )}
+      </gr-related-collapse>
+      <div class="note" ?hidden=${!countNonVisibleChanges}>
+        (+ ${pluralize(countNonVisibleChanges, 'non-visible change')})
+      </div>
+    </section>`;
+
+    const sameTopicMarkersPredicate = this.markersPredicateFactory(
+      this.sameTopicChanges.length,
+      -1,
+      sectionSize(Section.SAME_TOPIC)
+    );
+    isFirstNonEmpty =
+      !firstNonEmptySectionFound && !!this.sameTopicChanges?.length;
+    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
+    const sameTopicSection = html`<section
+      id="sameTopic"
+      ?hidden=${!this.sameTopicChanges?.length}
+    >
+      <gr-related-collapse
+        title="Same topic"
+        class="${classMap({first: isFirstNonEmpty})}"
+        .length=${this.sameTopicChanges.length}
+        .numChangesWhenCollapsed=${sectionSize(Section.SAME_TOPIC)}
+      >
+        ${this.sameTopicChanges.map(
+          (change, index) =>
+            html`${this.renderMarkers(
+                sameTopicMarkersPredicate(index)
+              )}<gr-related-change
+                class="${classMap({
+                  ['show-when-collapsed']: sameTopicMarkersPredicate(index)
+                    .showWhenCollapsed,
+                })}"
+                .change="${change}"
+                .href="${GerritNav.getUrlForChangeById(
+                  change._number,
+                  change.project
+                )}"
+                >${change.project}: ${change.branch}:
+                ${change.subject}</gr-related-change
+              >`
+        )}
+      </gr-related-collapse>
+    </section>`;
+
+    const mergeConflictsMarkersPredicate = this.markersPredicateFactory(
+      this.conflictingChanges.length,
+      -1,
+      sectionSize(Section.MERGE_CONFLICTS)
+    );
+    isFirstNonEmpty =
+      !firstNonEmptySectionFound && !!this.conflictingChanges?.length;
+    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
+    const mergeConflictsSection = html`<section
+      id="mergeConflicts"
+      ?hidden=${!this.conflictingChanges?.length}
+    >
+      <gr-related-collapse
+        title="Merge conflicts"
+        class="${classMap({first: isFirstNonEmpty})}"
+        .length=${this.conflictingChanges.length}
+        .numChangesWhenCollapsed=${sectionSize(Section.MERGE_CONFLICTS)}
+      >
+        ${this.conflictingChanges.map(
+          (change, index) =>
+            html`${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
+              >`
+        )}
+      </gr-related-collapse>
+    </section>`;
+
+    const cherryPicksMarkersPredicate = this.markersPredicateFactory(
+      this.cherryPickChanges.length,
+      -1,
+      sectionSize(Section.CHERRY_PICKS)
+    );
+    isFirstNonEmpty =
+      !firstNonEmptySectionFound && !!this.cherryPickChanges?.length;
+    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
+    const cherryPicksSection = html`<section
+      id="cherryPicks"
+      ?hidden=${!this.cherryPickChanges?.length}
+    >
+      <gr-related-collapse
+        title="Cherry picks"
+        class="${classMap({first: isFirstNonEmpty})}"
+        .length=${this.cherryPickChanges.length}
+        .numChangesWhenCollapsed=${sectionSize(Section.CHERRY_PICKS)}
+      >
+        ${this.cherryPickChanges.map(
+          (change, index) =>
+            html`${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
+              >`
+        )}
+      </gr-related-collapse>
+    </section>`;
+
+    return html`<gr-endpoint-decorator name="related-changes-section">
+      <gr-endpoint-param
+        name="change"
+        .value=${this.change}
+      ></gr-endpoint-param>
+      <gr-endpoint-slot name="top"></gr-endpoint-slot>
+      ${relatedChangeSection} ${submittedTogetherSection} ${sameTopicSection}
+      ${mergeConflictsSection} ${cherryPicksSection}
+      <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
+    </gr-endpoint-decorator>`;
+  }
+
+  sectionSizeFactory(
+    relatedChangesLen: number,
+    submittedTogetherLen: number,
+    sameTopicLen: number,
+    mergeConflictsLen: number,
+    cherryPicksLen: number
+  ) {
+    const calcDefaultSize = (length: number) =>
+      Math.min(length, DEFALT_NUM_CHANGES_WHEN_COLLAPSED);
+
+    const sectionSizes = [
+      {
+        section: Section.RELATED_CHANGES,
+        size: calcDefaultSize(relatedChangesLen),
+        len: relatedChangesLen,
+      },
+      {
+        section: Section.SUBMITTED_TOGETHER,
+        size: calcDefaultSize(submittedTogetherLen),
+        len: submittedTogetherLen,
+      },
+      {
+        section: Section.SAME_TOPIC,
+        size: calcDefaultSize(sameTopicLen),
+        len: sameTopicLen,
+      },
+      {
+        section: Section.MERGE_CONFLICTS,
+        size: calcDefaultSize(mergeConflictsLen),
+        len: mergeConflictsLen,
+      },
+      {
+        section: Section.CHERRY_PICKS,
+        size: calcDefaultSize(cherryPicksLen),
+        len: cherryPicksLen,
+      },
+    ];
+
+    const FILLER = 1; // space for header
+    let totalSize = sectionSizes.reduce(
+      (acc, val) => acc + val.size + (val.size !== 0 ? FILLER : 0),
+      0
+    );
+
+    const MAX_SIZE = 16;
+    for (let i = 0; i < sectionSizes.length; i++) {
+      if (totalSize >= MAX_SIZE) break;
+      const sizeObj = sectionSizes[i];
+      if (sizeObj.size === sizeObj.len) continue;
+      const newSize = Math.min(
+        MAX_SIZE - totalSize + sizeObj.size,
+        sizeObj.len
+      );
+      totalSize += newSize - sizeObj.size;
+      sizeObj.size = newSize;
     }
+
+    return (section: Section) => {
+      const sizeObj = sectionSizes.find(sizeObj => sizeObj.section === section);
+      if (sizeObj) return sizeObj.size;
+      return DEFALT_NUM_CHANGES_WHEN_COLLAPSED;
+    };
+  }
+
+  markersPredicateFactory(
+    length: number,
+    highlightIndex: number,
+    numChangesShownWhenCollapsed = DEFALT_NUM_CHANGES_WHEN_COLLAPSED
+  ): (index: number) => ChangeMarkersInList {
+    const showWhenCollapsedPredicate = (index: number) => {
+      if (highlightIndex === -1) return index < numChangesShownWhenCollapsed;
+      if (highlightIndex === 0)
+        return index <= numChangesShownWhenCollapsed - 1;
+      if (highlightIndex === length - 1)
+        return index >= length - numChangesShownWhenCollapsed;
+      let numBeforeHighlight = Math.floor(numChangesShownWhenCollapsed / 2);
+      let numAfterHighlight =
+        Math.floor(numChangesShownWhenCollapsed / 2) -
+        (numChangesShownWhenCollapsed % 2 ? 0 : 1);
+      numBeforeHighlight += Math.max(
+        highlightIndex + numAfterHighlight - length + 1,
+        0
+      );
+      numAfterHighlight -= Math.min(0, highlightIndex - numBeforeHighlight);
+      return (
+        highlightIndex - numBeforeHighlight <= index &&
+        index <= highlightIndex + numAfterHighlight
+      );
+    };
+    return (index: number) => {
+      return {
+        showCurrentChangeArrow:
+          highlightIndex !== -1 && index === highlightIndex,
+        showWhenCollapsed: showWhenCollapsedPredicate(index),
+        showTopArrow:
+          index >= 1 &&
+          index !== highlightIndex &&
+          showWhenCollapsedPredicate(index) &&
+          !showWhenCollapsedPredicate(index - 1),
+        showBottomArrow:
+          index <= length - 2 &&
+          index !== highlightIndex &&
+          showWhenCollapsedPredicate(index) &&
+          !showWhenCollapsedPredicate(index + 1),
+      };
+    };
+  }
+
+  renderMarkers(changeMarkers: ChangeMarkersInList) {
+    if (changeMarkers.showCurrentChangeArrow) {
+      return html`<span
+        role="img"
+        class="arrowToCurrentChange"
+        aria-label="Arrow marking current change"
+        >➔</span
+      >`;
+    }
+    if (changeMarkers.showTopArrow) {
+      return html`<span
+        role="img"
+        class="marker"
+        aria-label="Arrow marking change has collapsed ancestors"
+        ><iron-icon icon="gr-icons:arrowDropUp"></iron-icon
+      ></span> `;
+    }
+    if (changeMarkers.showBottomArrow) {
+      return html`<span
+        role="img"
+        class="marker"
+        aria-label="Arrow marking change has collapsed descendants"
+        ><iron-icon icon="gr-icons:arrowDropDown"></iron-icon
+      ></span> `;
+    }
+    return nothing;
+  }
+
+  reload(getRelatedChanges?: Promise<RelatedChangesInfo | undefined>) {
     const change = this.change;
-    this.loading = true;
+    if (!change) return Promise.reject(new Error('change missing'));
+    if (!this.patchNum) return Promise.reject(new Error('patchNum missing'));
+    if (!getRelatedChanges) {
+      getRelatedChanges = this.restApiService.getRelatedChanges(
+        change._number,
+        this.patchNum
+      );
+    }
     const promises: Array<Promise<void>> = [
-      this.$.restAPI
-        .getRelatedChanges(change._number, this.patchNum)
-        .then(response => {
-          if (!response) {
-            throw new Error('getRelatedChanges returned undefined response');
-          }
-          this._relatedResponse = response;
-          this._fireReloadEvent();
-          this.hasParent = this._calculateHasParent(
-            change.change_id,
-            response.changes
-          );
-        }),
-      this.$.restAPI
+      getRelatedChanges.then(response => {
+        if (!response) {
+          throw new Error('getRelatedChanges returned undefined response');
+        }
+        this.relatedChanges = response?.changes ?? [];
+      }),
+      this.restApiService
         .getChangesSubmittedTogether(change._number)
         .then(response => {
-          this._submittedTogether = response;
-          this._fireReloadEvent();
+          this.submittedTogether = response;
         }),
-      this.$.restAPI
+      this.restApiService
         .getChangeCherryPicks(change.project, change.change_id, change._number)
         .then(response => {
-          this._cherryPicks = response || [];
-          this._fireReloadEvent();
+          this.cherryPickChanges = response || [];
         }),
     ];
 
     // Get conflicts if change is open and is mergeable.
+    // Mergeable is output of restApiServict.getMergeable from gr-change-view
     if (changeIsOpen(change) && this.mergeable) {
       promises.push(
-        this.$.restAPI.getChangeConflicts(change._number).then(response => {
-          // Because the server doesn't always return a response and the
-          // template expects an array, always return an array.
-          this._conflicts = response ? response : [];
-          this._fireReloadEvent();
+        this.restApiService
+          .getChangeConflicts(change._number)
+          .then(response => {
+            this.conflictingChanges = response ?? [];
+          })
+      );
+    }
+    if (change.topic) {
+      const changeTopic = change.topic;
+      promises.push(
+        this.restApiService.getConfig().then(config => {
+          if (config && !config.change.submit_whole_topic) {
+            return this.restApiService
+              .getChangesWithSameTopic(changeTopic, change._number)
+              .then(response => {
+                if (changeTopic === this.change?.topic) {
+                  this.sameTopicChanges = response ?? [];
+                }
+              });
+          }
+          this.sameTopicChanges = [];
+          return Promise.resolve();
         })
       );
     }
 
-    promises.push(
-      this._getServerConfig().then(config => {
-        if (change.topic) {
-          if (!config) {
-            throw new Error('_getServerConfig returned undefined ');
-          }
-          if (!config.change.submit_whole_topic) {
-            return this.$.restAPI
-              .getChangesWithSameTopic(change.topic, change._number)
-              .then(response => {
-                this._sameTopic = response;
-              });
-          }
-        }
-        this._sameTopic = [];
-        return Promise.resolve();
-      })
-    );
-
-    return Promise.all(promises).then(() => {
-      this.loading = false;
-    });
-  }
-
-  _fireReloadEvent() {
-    // The listener on the change computes height of the related changes
-    // section, so they have to be rendered first, and inside a dom-repeat,
-    // that requires a flush.
-    flush();
-    this.dispatchEvent(new CustomEvent('new-section-loaded'));
-  }
-
-  /**
-   * Determines whether or not the given change has a parent change. If there
-   * is a relation chain, and the change id is not the last item of the
-   * relation chain, there is a parent.
-   */
-  _calculateHasParent(
-    currentChangeId: ChangeId,
-    relatedChanges: RelatedChangeAndCommitInfo[]
-  ) {
-    return (
-      relatedChanges.length > 0 &&
-      relatedChanges[relatedChanges.length - 1].change_id !== currentChangeId
-    );
-  }
-
-  _getServerConfig() {
-    return this.$.restAPI.getConfig();
-  }
-
-  _computeChangeURL(
-    changeNum: NumericChangeId,
-    project: RepoName,
-    patchNum?: PatchSetNum
-  ) {
-    return GerritNav.getUrlForChangeById(changeNum, project, patchNum);
+    return Promise.all(promises);
   }
 
   /**
@@ -238,8 +562,8 @@
    * their numbers.
    */
   _changesEqual(
-    a: ChangeInfo | RelatedChangeAndCommitInfo,
-    b: ChangeInfo | RelatedChangeAndCommitInfo
+    a?: ChangeInfo | RelatedChangeAndCommitInfo,
+    b?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
   ) {
     const aNum = this._getChangeNumber(a);
     const bNum = this._getChangeNumber(b);
@@ -252,7 +576,9 @@
    * RelatedChangeAndCommitInfo (such as those included in a
    * RelatedChangesInfo response).
    */
-  _getChangeNumber(change?: ChangeInfo | RelatedChangeAndCommitInfo) {
+  _getChangeNumber(
+    change?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
+  ) {
     // Default to 0 if change property is not defined.
     if (!change) return 0;
 
@@ -262,160 +588,21 @@
     return change._change_number;
   }
 
-  _computeLinkClass(change: ParsedChangeInfo) {
-    const statuses = [];
-    if (change.status === ChangeStatus.ABANDONED) {
-      statuses.push('strikethrough');
-    }
-    if (change.submittable) {
-      statuses.push('submittable');
-    }
-    return statuses.join(' ');
-  }
-
-  _computeChangeStatusClass(change: RelatedChangeAndCommitInfo) {
-    const classes = ['status'];
-    if (change._revision_number !== change._current_revision_number) {
-      classes.push('notCurrent');
-    } else if (this._isIndirectAncestor(change)) {
-      classes.push('indirectAncestor');
-    } else if (change.submittable) {
-      classes.push('submittable');
-    } else if (change.status === ChangeStatus.NEW) {
-      classes.push('hidden');
-    }
-    return classes.join(' ');
-  }
-
-  _computeChangeStatus(change: RelatedChangeAndCommitInfo) {
-    switch (change.status) {
-      case ChangeStatus.MERGED:
-        return 'Merged';
-      case ChangeStatus.ABANDONED:
-        return 'Abandoned';
-    }
-    if (change._revision_number !== change._current_revision_number) {
-      return 'Not current';
-    } else if (this._isIndirectAncestor(change)) {
-      return 'Indirect ancestor';
-    } else if (change.submittable) {
-      return 'Submittable';
-    }
-    return '';
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    // We listen to `new-section-loaded` events to allow plugins to trigger
-    // visibility computations, if their content or visibility changed.
-    this.addEventListener('new-section-loaded', () =>
-      this._handleNewSectionLoaded()
-    );
-  }
-
-  _handleNewSectionLoaded() {
-    // A plugin sent a `new-section-loaded` event, so its visibility likely
-    // changed. Hence, we update our visibility if needed.
-    this._resultsChanged(
-      this._relatedResponse,
-      this._submittedTogether,
-      this._conflicts,
-      this._cherryPicks,
-      this._sameTopic
-    );
-  }
-
-  @observe(
-    '_relatedResponse',
-    '_submittedTogether',
-    '_conflicts',
-    '_cherryPicks',
-    '_sameTopic'
-  )
-  _resultsChanged(
-    related: RelatedChangesInfo,
-    submittedTogether: SubmittedTogetherInfo | undefined,
-    conflicts: ChangeInfo[],
-    cherryPicks: ChangeInfo[],
-    sameTopic?: ChangeInfo[]
-  ) {
-    if (!submittedTogether || !sameTopic) {
-      return;
-    }
-    const submittedTogetherChangesCount =
-      (submittedTogether.changes || []).length +
-      (submittedTogether.non_visible_changes || 0);
-    const results = [
-      related && related.changes,
-      // If there are either visible or non-visible changes, we need a
-      // non-empty list to fire the event and set visibility.
-      submittedTogetherChangesCount ? [{}] : [],
-      conflicts,
-      cherryPicks,
-      sameTopic,
-    ];
-    for (let i = 0; i < results.length; i++) {
-      if (results[i] && results[i].length > 0) {
-        this.hidden = false;
-        this.dispatchEvent(
-          new CustomEvent('update', {
-            composed: true,
-            bubbles: false,
-          })
-        );
-        return;
-      }
-    }
-
-    this._computeHidden();
-  }
-
-  _computeHidden() {
-    // None of the built-in change lists had elements. So all of them are
-    // hidden. But since plugins might have injected visible content, we need
-    // to check for that and stay visible if we find any such visible content.
-    // (We consider plugins visible except if it's main element has the hidden
-    // attribute set to true.)
-    const plugins = getPluginEndpoints().getDetails('related-changes-section');
-    this.hidden = !plugins.some(
-      plugin =>
-        !plugin.domHook ||
-        plugin.domHook.getAllAttached().some(instance => !instance.hidden)
-    );
-  }
-
-  _isIndirectAncestor(change: RelatedChangeAndCommitInfo) {
-    return (
-      this._connectedRevisions &&
-      !this._connectedRevisions.includes(change.commit.commit)
-    );
-  }
-
+  /*
+   * A list of commit ids connected to change to understand if other change
+   * is direct or indirect ancestor / descendant.
+   */
   _computeConnectedRevisions(
     change?: ParsedChangeInfo,
     patchNum?: PatchSetNum,
     relatedChanges?: RelatedChangeAndCommitInfo[]
   ) {
-    // Polymer 2: check for undefined
-    if (
-      change === undefined ||
-      patchNum === undefined ||
-      relatedChanges === undefined
-    ) {
-      return undefined;
+    if (!patchNum || !relatedChanges || !change) {
+      return [];
     }
 
     const connected: CommitId[] = [];
-    let changeRevision;
-    if (!change) {
-      return [];
-    }
-    for (const rev in change.revisions) {
-      if (patchNumEquals(change.revisions[rev]._number, patchNum)) {
-        changeRevision = rev;
-      }
-    }
+    const changeRevision = getRevisionKey(change, patchNum);
     const commits = relatedChanges.map(c => c.commit);
     let pos = commits.length - 1;
 
@@ -440,26 +627,128 @@
     }
     return connected;
   }
+}
 
-  _computeSubmittedTogetherClass(submittedTogether?: SubmittedTogetherInfo) {
-    if (
-      !submittedTogether ||
-      (submittedTogether.changes.length === 0 &&
-        !submittedTogether.non_visible_changes)
-    ) {
-      return 'hidden';
-    }
-    return '';
+@customElement('gr-related-collapse')
+export class GrRelatedCollapse extends GrLitElement {
+  @property()
+  title = '';
+
+  @property()
+  showAll = false;
+
+  @property()
+  length = 0;
+
+  @property()
+  numChangesWhenCollapsed = DEFALT_NUM_CHANGES_WHEN_COLLAPSED;
+
+  private readonly reporting = appContext.reportingService;
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        .title {
+          font-weight: var(--font-weight-bold);
+          color: var(--deemphasized-text-color);
+          padding-left: var(--metadata-horizontal-padding);
+        }
+        h4 {
+          display: flex;
+          align-self: flex-end;
+        }
+        gr-button {
+          display: flex;
+        }
+        /* This is a hacky solution from old gr-related-change-list
+         * TODO(milutin): find layout without needing it
+         */
+        h4:before,
+        gr-button:before,
+        ::slotted(gr-related-change):before {
+          content: ' ';
+          flex-shrink: 0;
+          width: 1.2em;
+        }
+        .collapsed ::slotted(gr-related-change.show-when-collapsed) {
+          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;
+          --iron-icon-width: 18px;
+        }
+        .container {
+          justify-content: space-between;
+          display: flex;
+          margin-bottom: var(--spacing-s);
+        }
+        :host(.first) .container {
+          margin-bottom: var(--spacing-m);
+        }
+      `,
+    ];
   }
 
-  _computeNonVisibleChangesNote(n: number) {
-    const noun = n === 1 ? 'change' : 'changes';
-    return `(+ ${n} non-visible ${noun})`;
+  render() {
+    const title = html`<h4 class="title">${this.title}</h4>`;
+
+    const collapsible = this.length > this.numChangesWhenCollapsed;
+    const items = html` <div
+      class="${!this.showAll && collapsible ? 'collapsed' : 'show-all'}"
+    >
+      <slot></slot>
+    </div>`;
+
+    let button: TemplateResult | typeof nothing = nothing;
+    if (collapsible) {
+      let buttonText = 'Show less';
+      let buttonIcon = 'expand-less';
+      if (!this.showAll) {
+        buttonText = `Show all (${this.length})`;
+        buttonIcon = 'expand-more';
+      }
+      button = html`<gr-button link="" @click="${this.toggle}"
+        >${buttonText}<iron-icon icon="gr-icons:${buttonIcon}"></iron-icon
+      ></gr-button>`;
+    }
+
+    return html`<div class="container">${title}${button}</div>
+      ${items}`;
+  }
+
+  private toggle(e: MouseEvent) {
+    e.stopPropagation();
+    this.showAll = !this.showAll;
+    this.reporting.reportInteraction('toggle show all button', {
+      sectionName: this.title,
+      toState: this.showAll ? 'Show all' : 'Show less',
+    });
   }
 }
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-related-changes-list': GrRelatedChangesList;
+    'gr-related-collapse': GrRelatedCollapse;
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
deleted file mode 100644
index d4cd0f6..0000000
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
+++ /dev/null
@@ -1,218 +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;
-    }
-    section {
-      margin-bottom: 1.4em; /* Same as line height for collapse purposes */
-    }
-    a {
-      display: block;
-    }
-    .changeContainer,
-    a {
-      max-width: 100%;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-    .changeContainer {
-      display: flex;
-    }
-    .arrowToCurrentChange {
-      position: absolute;
-    }
-    h4,
-    section div {
-      display: flex;
-    }
-    h4:before,
-    section div:before {
-      content: ' ';
-      flex-shrink: 0;
-      width: 1.2em;
-    }
-    .note {
-      color: var(--error-text-color);
-    }
-    .relatedChanges a {
-      display: inline-block;
-    }
-    .strikethrough {
-      color: var(--deemphasized-text-color);
-      text-decoration: line-through;
-    }
-    .status {
-      color: var(--deemphasized-text-color);
-      font-weight: var(--font-weight-bold);
-      margin-left: var(--spacing-xs);
-    }
-    .notCurrent {
-      color: #e65100;
-    }
-    .indirectAncestor {
-      color: #33691e;
-    }
-    .submittableCheck {
-      padding-left: var(--spacing-s);
-      color: var(--positive-green-text-color);
-      display: none;
-    }
-    .submittableCheck.submittable {
-      display: inline;
-    }
-    .hidden,
-    .mobile {
-      display: none;
-    }
-    @media screen and (max-width: 60em) {
-      .mobile {
-        display: block;
-      }
-    }
-  </style>
-  <div>
-    <gr-endpoint-decorator name="related-changes-section">
-      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-      <gr-endpoint-slot name="top"></gr-endpoint-slot>
-      <section
-        class="relatedChanges"
-        hidden$="[[!_relatedResponse.changes.length]]"
-        hidden=""
-      >
-        <h4>Relation chain</h4>
-        <template
-          is="dom-repeat"
-          items="[[_relatedResponse.changes]]"
-          as="related"
-        >
-          <template is="dom-if" if="[[_changesEqual(related, change)]]">
-            <span
-              role="img"
-              class="arrowToCurrentChange"
-              aria-label="Arrow marking current change"
-              >➔</span
-            >
-          </template>
-          <div class="rightIndent changeContainer">
-            <a
-              href$="[[_computeChangeURL(related._change_number, related.project, related._revision_number)]]"
-              class$="[[_computeLinkClass(related)]]"
-              title$="[[related.commit.subject]]"
-            >
-              [[related.commit.subject]]
-            </a>
-            <span class$="[[_computeChangeStatusClass(related)]]">
-              ([[_computeChangeStatus(related)]])
-            </span>
-          </div>
-        </template>
-      </section>
-      <section
-        id="submittedTogether"
-        class$="[[_computeSubmittedTogetherClass(_submittedTogether)]]"
-      >
-        <h4>Submitted together</h4>
-        <template
-          is="dom-repeat"
-          items="[[_submittedTogether.changes]]"
-          as="related"
-        >
-          <template is="dom-if" if="[[_changesEqual(related, change)]]">
-            <span
-              role="img"
-              class="arrowToCurrentChange"
-              aria-label="Arrow marking current change"
-              >➔</span
-            >
-          </template>
-          <div class="changeContainer">
-            <a
-              href$="[[_computeChangeURL(related._number, related.project)]]"
-              class$="[[_computeLinkClass(related)]]"
-              title$="[[related.project]]: [[related.branch]]: [[related.subject]]"
-            >
-              [[related.project]]: [[related.branch]]: [[related.subject]]
-            </a>
-            <span
-              tabindex="-1"
-              title="Submittable"
-              class$="submittableCheck [[_computeLinkClass(related)]]"
-              role="img"
-              aria-label="Submittable"
-              >✓</span
-            >
-          </div>
-        </template>
-        <template is="dom-if" if="[[_submittedTogether.non_visible_changes]]">
-          <div class="note">
-            [[_computeNonVisibleChangesNote(_submittedTogether.non_visible_changes)]]
-          </div>
-        </template>
-      </section>
-      <section hidden$="[[!_sameTopic.length]]" hidden="">
-        <h4>Same topic</h4>
-        <template is="dom-repeat" items="[[_sameTopic]]" as="change">
-          <div>
-            <a
-              href$="[[_computeChangeURL(change._number, change.project)]]"
-              class$="[[_computeLinkClass(change)]]"
-              title$="[[change.project]]: [[change.branch]]: [[change.subject]]"
-            >
-              [[change.project]]: [[change.branch]]: [[change.subject]]
-            </a>
-          </div>
-        </template>
-      </section>
-      <section hidden$="[[!_conflicts.length]]" hidden="">
-        <h4>Merge conflicts</h4>
-        <template is="dom-repeat" items="[[_conflicts]]" as="change">
-          <div>
-            <a
-              href$="[[_computeChangeURL(change._number, change.project)]]"
-              class$="[[_computeLinkClass(change)]]"
-              title$="[[change.subject]]"
-            >
-              [[change.subject]]
-            </a>
-          </div>
-        </template>
-      </section>
-      <section hidden$="[[!_cherryPicks.length]]" hidden="">
-        <h4>Cherry picks</h4>
-        <template is="dom-repeat" items="[[_cherryPicks]]" as="change">
-          <div>
-            <a
-              href$="[[_computeChangeURL(change._number, change.project)]]"
-              class$="[[_computeLinkClass(change)]]"
-              title$="[[change.branch]]: [[change.subject]]"
-            >
-              [[change.branch]]: [[change.subject]]
-            </a>
-          </div>
-        </template>
-      </section>
-      <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
-    </gr-endpoint-decorator>
-  </div>
-  <div hidden$="[[!loading]]">Loading...</div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js
deleted file mode 100644
index 3983c5a..0000000
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js
+++ /dev/null
@@ -1,641 +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-related-changes-list.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-const basicFixture = fixtureFromElement('gr-related-changes-list');
-
-suite('gr-related-changes-list tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('connected revisions', () => {
-    const change = {
-      revisions: {
-        'e3c6d60783bfdec9ebae7dcfec4662360433449e': {
-          _number: 1,
-        },
-        '26e5e4c9c7ae31cbd876271cca281ce22b413997': {
-          _number: 2,
-        },
-        'bf7884d695296ca0c91702ba3e2bc8df0f69a907': {
-          _number: 7,
-        },
-        'b5fc49f2e67d1889d5275cac04ad3648f2ec7fe3': {
-          _number: 5,
-        },
-        'd6bcee67570859ccb684873a85cf50b1f0e96fda': {
-          _number: 6,
-        },
-        'cc960918a7f90388f4a9e05753d0f7b90ad44546': {
-          _number: 3,
-        },
-        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': {
-          _number: 4,
-        },
-      },
-    };
-    let patchNum = 7;
-    let relatedChanges = [
-      {
-        commit: {
-          commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
-          parents: [
-            {
-              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-          parents: [
-            {
-              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-          parents: [
-            {
-              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-          parents: [
-            {
-              commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-          parents: [
-            {
-              commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
-          parents: [
-            {
-              commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75',
-            },
-          ],
-        },
-      },
-    ];
-
-    let connectedChanges =
-        element._computeConnectedRevisions(change, patchNum, relatedChanges);
-    assert.deepEqual(connectedChanges, [
-      '613bc4f81741a559c6667ac08d71dcc3348f73ce',
-      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-      'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-      '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-      '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-      '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
-    ]);
-
-    patchNum = 4;
-    relatedChanges = [
-      {
-        commit: {
-          commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
-          parents: [
-            {
-              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-          parents: [
-            {
-              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-          parents: [
-            {
-              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: 'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
-          parents: [
-            {
-              commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-          parents: [
-            {
-              commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
-          parents: [
-            {
-              commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c',
-            },
-          ],
-        },
-      },
-    ];
-
-    connectedChanges =
-        element._computeConnectedRevisions(change, patchNum, relatedChanges);
-    assert.deepEqual(connectedChanges, [
-      'af815dac54318826b7f1fa468acc76349ffc588e',
-      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-      'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
-    ]);
-  });
-
-  test('_changesEqual', () => {
-    const change1 = {change_id: 123, _number: 0};
-    const change2 = {change_id: 456, _number: 1};
-    const change3 = {change_id: 123, _number: 2};
-    const change4 = {change_id: 123, _change_number: 1};
-
-    assert.isTrue(element._changesEqual(change1, change1));
-    assert.isFalse(element._changesEqual(change1, change2));
-    assert.isFalse(element._changesEqual(change1, change3));
-    assert.isTrue(element._changesEqual(change2, change4));
-  });
-
-  test('_getChangeNumber', () => {
-    const change1 = {change_id: 123, _number: 0};
-    const change2 = {change_id: 456, _change_number: 1};
-    assert.equal(element._getChangeNumber(change1), 0);
-    assert.equal(element._getChangeNumber(change2), 1);
-  });
-
-  test('event for section loaded fires for each section ', () => {
-    const loadedStub = sinon.stub();
-    element.patchNum = 7;
-    element.change = {
-      change_id: 123,
-      status: 'NEW',
-    };
-    element.mergeable = true;
-    element.addEventListener('new-section-loaded', loadedStub);
-    sinon.stub(element.$.restAPI, 'getRelatedChanges')
-        .returns(Promise.resolve({changes: []}));
-    sinon.stub(element.$.restAPI, 'getChangesSubmittedTogether')
-        .returns(Promise.resolve());
-    sinon.stub(element.$.restAPI, 'getChangeCherryPicks')
-        .returns(Promise.resolve());
-    sinon.stub(element.$.restAPI, 'getChangeConflicts')
-        .returns(Promise.resolve());
-
-    return element.reload().then(() => {
-      assert.equal(loadedStub.callCount, 4);
-    });
-  });
-
-  suite('getChangeConflicts resolves undefined', () => {
-    let element;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-
-      sinon.stub(element.$.restAPI, 'getRelatedChanges')
-          .returns(Promise.resolve({changes: []}));
-      sinon.stub(element.$.restAPI, 'getChangesSubmittedTogether')
-          .returns(Promise.resolve());
-      sinon.stub(element.$.restAPI, 'getChangeCherryPicks')
-          .returns(Promise.resolve());
-      sinon.stub(element.$.restAPI, 'getChangeConflicts')
-          .returns(Promise.resolve());
-    });
-
-    test('_conflicts are an empty array', () => {
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'NEW',
-      };
-      element.mergeable = true;
-      element.reload();
-      assert.equal(element._conflicts.length, 0);
-    });
-  });
-
-  suite('get conflicts tests', () => {
-    let element;
-    let conflictsStub;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-
-      sinon.stub(element.$.restAPI, 'getRelatedChanges')
-          .returns(Promise.resolve({changes: []}));
-      sinon.stub(element.$.restAPI, 'getChangesSubmittedTogether')
-          .returns(Promise.resolve());
-      sinon.stub(element.$.restAPI, 'getChangeCherryPicks')
-          .returns(Promise.resolve());
-      conflictsStub = sinon.stub(element.$.restAPI, 'getChangeConflicts')
-          .returns(Promise.resolve());
-    });
-
-    test('request conflicts if open and mergeable', () => {
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'NEW',
-      };
-      element.mergeable = true;
-      element.reload();
-      assert.isTrue(conflictsStub.called);
-    });
-
-    test('does not request conflicts if closed and mergeable', () => {
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'MERGED',
-      };
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-
-    test('does not request conflicts if open and not mergeable', () => {
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'NEW',
-      };
-      element.mergeable = false;
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-
-    test('doesnt request conflicts if closed and not mergeable', () => {
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'MERGED',
-      };
-      element.mergeable = false;
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-  });
-
-  test('_calculateHasParent', () => {
-    const changeId = 123;
-    const relatedChanges = [];
-
-    assert.equal(element._calculateHasParent(changeId, relatedChanges),
-        false);
-
-    relatedChanges.push({change_id: 123});
-    assert.equal(element._calculateHasParent(changeId, relatedChanges),
-        false);
-
-    relatedChanges.push({change_id: 234});
-    assert.equal(element._calculateHasParent(changeId, relatedChanges),
-        true);
-  });
-
-  suite('hidden attribute and update event', () => {
-    const changes = [{
-      project: 'foo/bar',
-      change_id: 'Ideadbeef',
-      commit: {
-        commit: 'deadbeef',
-        parents: [{commit: 'abc123'}],
-        author: {},
-        subject: 'do that thing',
-      },
-      _change_number: 12345,
-      _revision_number: 1,
-      _current_revision_number: 1,
-      status: 'NEW',
-    }];
-
-    test('clear and empties', () => {
-      element._relatedResponse = {changes};
-      element._submittedTogether = {changes};
-      element._conflicts = changes;
-      element._cherryPicks = changes;
-      element._sameTopic = changes;
-
-      element.hidden = false;
-      element.clear();
-      assert.isTrue(element.hidden);
-      assert.equal(element._relatedResponse.changes.length, 0);
-      assert.equal(element._submittedTogether.changes.length, 0);
-      assert.equal(element._conflicts.length, 0);
-      assert.equal(element._cherryPicks.length, 0);
-      assert.equal(element._sameTopic.length, 0);
-    });
-
-    test('update fires', () => {
-      const updateHandler = sinon.stub();
-      element.addEventListener('update', updateHandler);
-
-      element._resultsChanged({}, {}, [], [], []);
-      assert.isTrue(element.hidden);
-      assert.isFalse(updateHandler.called);
-
-      element._resultsChanged({}, {}, [], [], ['test']);
-      assert.isFalse(element.hidden);
-      assert.isTrue(updateHandler.called);
-      updateHandler.reset();
-
-      element._resultsChanged(
-          {}, {changes: [], non_visible_changes: 0}, [], [], []);
-      assert.isTrue(element.hidden);
-      assert.isFalse(updateHandler.called);
-
-      element._resultsChanged(
-          {}, {changes: ['test'], non_visible_changes: 0}, [], [], []);
-      assert.isFalse(element.hidden);
-      assert.isTrue(updateHandler.called);
-      updateHandler.reset();
-
-      element._resultsChanged(
-          {}, {changes: [], non_visible_changes: 1}, [], [], []);
-      assert.isFalse(element.hidden);
-      assert.isTrue(updateHandler.called);
-    });
-
-    suite('hiding and unhiding', () => {
-      test('related response', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged({changes}, {}, [], [], []);
-        assert.isFalse(element.hidden);
-      });
-
-      test('submitted together', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged({}, {changes}, [], [], []);
-        assert.isFalse(element.hidden);
-      });
-
-      test('conflicts', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged({}, {}, changes, [], []);
-        assert.isFalse(element.hidden);
-      });
-
-      test('cherrypicks', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged({}, {}, [], changes, []);
-        assert.isFalse(element.hidden);
-      });
-
-      test('same topic', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged({}, {}, [], [], changes);
-        assert.isFalse(element.hidden);
-      });
-    });
-  });
-
-  test('_computeChangeURL uses GerritNav', () => {
-    const getUrlStub = sinon.stub(GerritNav, 'getUrlForChangeById');
-    element._computeChangeURL(123, 'abc/def', 12);
-    assert.isTrue(getUrlStub.called);
-  });
-
-  suite('submitted together changes', () => {
-    const change = {
-      project: 'foo/bar',
-      change_id: 'Ideadbeef',
-      commit: {
-        commit: 'deadbeef',
-        parents: [{commit: 'abc123'}],
-        author: {},
-        subject: 'do that thing',
-      },
-      _change_number: 12345,
-      _revision_number: 1,
-      _current_revision_number: 1,
-      status: 'NEW',
-    };
-
-    test('_computeSubmittedTogetherClass', () => {
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass(undefined),
-          'hidden');
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass({changes: []}),
-          'hidden');
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass({changes: [{}]}),
-          '');
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass({
-            changes: [],
-            non_visible_changes: 0,
-          }),
-          'hidden');
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass({
-            changes: [],
-            non_visible_changes: 1,
-          }),
-          '');
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass({
-            changes: [{}],
-            non_visible_changes: 1,
-          }),
-          '');
-    });
-
-    test('no submitted together changes', () => {
-      flush();
-      assert.include(element.$.submittedTogether.className, 'hidden');
-    });
-
-    test('no non-visible submitted together changes', () => {
-      element._submittedTogether = {changes: [change]};
-      flush();
-      assert.notInclude(element.$.submittedTogether.className, 'hidden');
-      assert.isNull(element.shadowRoot
-          .querySelector('.note'));
-    });
-
-    test('no visible submitted together changes', () => {
-      // Technically this should never happen, but worth asserting the logic.
-      element._submittedTogether = {changes: [], non_visible_changes: 1};
-      flush();
-      assert.notInclude(element.$.submittedTogether.className, 'hidden');
-      assert.isNotNull(element.shadowRoot
-          .querySelector('.note'));
-      assert.strictEqual(
-          element.shadowRoot
-              .querySelector('.note').innerText.trim(),
-          '(+ 1 non-visible change)');
-    });
-
-    test('visible and non-visible submitted together changes', () => {
-      element._submittedTogether = {changes: [change], non_visible_changes: 2};
-      flush();
-      assert.notInclude(element.$.submittedTogether.className, 'hidden');
-      assert.isNotNull(element.shadowRoot
-          .querySelector('.note'));
-      assert.strictEqual(
-          element.shadowRoot
-              .querySelector('.note').innerText.trim(),
-          '(+ 2 non-visible changes)');
-    });
-  });
-});
-
-suite('gr-related-changes-list plugin tests', () => {
-  let element;
-
-  setup(() => {
-    resetPlugins();
-    element = basicFixture.instantiate();
-  });
-
-  teardown(() => {
-    resetPlugins();
-  });
-
-  test('endpoint params', done => {
-    element.change = {labels: {}};
-    let hookEl;
-    let plugin;
-    pluginApi.install(
-        p => {
-          plugin = p;
-          plugin.hook('related-changes-section').getLastAttached()
-              .then(el => hookEl = el);
-        },
-        '0.1',
-        'http://some/plugins/url1.html');
-    getPluginLoader().loadPlugins([]);
-    flush(() => {
-      assert.strictEqual(hookEl.plugin, plugin);
-      assert.strictEqual(hookEl.change, element.change);
-      done();
-    });
-  });
-
-  test('hiding and unhiding', done => {
-    element.change = {labels: {}};
-    let hookEl;
-    let plugin;
-
-    // No changes, and no plugin. The element is still hidden.
-    element._resultsChanged({}, {}, [], [], []);
-    assert.isTrue(element.hidden);
-    pluginApi.install(
-        p => {
-          plugin = p;
-          plugin.hook('related-changes-section').getLastAttached()
-              .then(el => hookEl = el);
-        },
-        '0.1',
-        'http://some/plugins/url2.html');
-    getPluginLoader().loadPlugins([]);
-    flush(() => {
-      // No changes, and plugin without hidden attribute. So it's visible.
-      element._resultsChanged({}, {}, [], [], []);
-      assert.isFalse(element.hidden);
-
-      // No changes, but plugin with true hidden attribute. So it's invisible.
-      hookEl.hidden = true;
-
-      element._resultsChanged({}, {}, [], [], []);
-      assert.isTrue(element.hidden);
-
-      // No changes, and plugin with false hidden attribute. So it's visible.
-      hookEl.hidden = false;
-      element._resultsChanged({}, {}, [], [], []);
-      assert.isFalse(element.hidden);
-
-      // Hiding triggered by plugin itself
-      hookEl.hidden = true;
-      hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
-        composed: true, bubbles: true,
-      }));
-      assert.isTrue(element.hidden);
-
-      // Unhiding triggered by plugin itself
-      hookEl.hidden = false;
-      hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
-        composed: true, bubbles: true,
-      }));
-      assert.isFalse(element.hidden);
-
-      // Hiding plugin keeps list visible, if there are changes
-      hookEl.hidden = false;
-      element._sameTopic = ['test'];
-      element._resultsChanged({}, {}, [], [], ['test']);
-      assert.isFalse(element.hidden);
-      hookEl.hidden = true;
-      hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
-        composed: true, bubbles: true,
-      }));
-      assert.isFalse(element.hidden);
-
-      done();
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..ae9af4a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -0,0 +1,630 @@
+/**
+ * @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 {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {PluginApi} from '../../../api/plugin';
+import {ChangeStatus} from '../../../constants/constants';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import '../../../test/common-test-setup-karma';
+import {
+  createChange,
+  createCommitInfoWithRequiredCommit,
+  createParsedChange,
+  createRelatedChangeAndCommitInfo,
+  createRelatedChangesInfo,
+  createRevision,
+  createSubmittedTogetherInfo,
+} from '../../../test/test-data-generators';
+import {
+  queryAndAssert,
+  resetPlugins,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  ChangeId,
+  ChangeInfo,
+  CommitId,
+  NumericChangeId,
+  PatchSetNum,
+  RelatedChangeAndCommitInfo,
+  RelatedChangesInfo,
+  SubmittedTogetherInfo,
+} from '../../../types/common';
+import {ParsedChangeInfo} from '../../../types/types';
+import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import './gr-related-changes-list';
+import {
+  ChangeMarkersInList,
+  GrRelatedChangesList,
+  GrRelatedCollapse,
+  Section,
+} from './gr-related-changes-list';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+const basicFixture = fixtureFromElement('gr-related-changes-list');
+
+suite('gr-related-changes-list', () => {
+  let element: GrRelatedChangesList;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  suite('show when collapsed', () => {
+    function genBoolArray(
+      instructions: Array<{
+        len: number;
+        v: boolean;
+      }>
+    ) {
+      return instructions
+        .map(inst => Array.from({length: inst.len}, () => inst.v))
+        .reduce((acc, val) => acc.concat(val), []);
+    }
+
+    function checkShowWhenCollapsed(
+      expected: boolean[],
+      markersPredicate: (index: number) => ChangeMarkersInList,
+      msg: string
+    ) {
+      for (let i = 0; i < expected.length; i++) {
+        assert.equal(
+          markersPredicate(i).showWhenCollapsed,
+          expected[i],
+          `change on pos (${i}) ${msg}`
+        );
+      }
+    }
+
+    test('size 5', () => {
+      const markersPredicate = element.markersPredicateFactory(10, 4, 5);
+      const expectedCollapsing = genBoolArray([
+        {len: 2, v: false},
+        {len: 5, v: true},
+        {len: 3, v: false},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing,
+        markersPredicate,
+        'highlight 4, size 10, size 5'
+      );
+
+      const markersPredicate2 = element.markersPredicateFactory(10, 8, 5);
+      const expectedCollapsing2 = genBoolArray([
+        {len: 5, v: false},
+        {len: 5, v: true},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing2,
+        markersPredicate2,
+        'highlight 8, size 10, size 5'
+      );
+
+      const markersPredicate3 = element.markersPredicateFactory(10, 1, 5);
+      const expectedCollapsing3 = genBoolArray([
+        {len: 5, v: true},
+        {len: 5, v: false},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing3,
+        markersPredicate3,
+        'highlight 1, size 10, size 5'
+      );
+    });
+
+    test('size 4', () => {
+      const markersPredicate = element.markersPredicateFactory(10, 4, 4);
+      const expectedCollapsing = genBoolArray([
+        {len: 2, v: false},
+        {len: 4, v: true},
+        {len: 4, v: false},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing,
+        markersPredicate,
+        'highlight 4, len 10, size 4'
+      );
+
+      const markersPredicate2 = element.markersPredicateFactory(10, 8, 4);
+      const expectedCollapsing2 = genBoolArray([
+        {len: 6, v: false},
+        {len: 4, v: true},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing2,
+        markersPredicate2,
+        'highlight 8, len 10, size 4'
+      );
+
+      const markersPredicate3 = element.markersPredicateFactory(10, 1, 4);
+      const expectedCollapsing3 = genBoolArray([
+        {len: 4, v: true},
+        {len: 6, v: false},
+      ]);
+      checkShowWhenCollapsed(
+        expectedCollapsing3,
+        markersPredicate3,
+        'highlight 1, len 10, size 4'
+      );
+    });
+  });
+
+  suite('section size', () => {
+    test('1 section', () => {
+      const sectionSize = element.sectionSizeFactory(20, 0, 0, 0, 0);
+      assert.equal(sectionSize(Section.RELATED_CHANGES), 15);
+      const sectionSize2 = element.sectionSizeFactory(0, 0, 10, 0, 0);
+      assert.equal(sectionSize2(Section.SAME_TOPIC), 10);
+    });
+    test('2 sections', () => {
+      const sectionSize = element.sectionSizeFactory(20, 20, 0, 0, 0);
+      assert.equal(sectionSize(Section.RELATED_CHANGES), 11);
+      assert.equal(sectionSize(Section.SUBMITTED_TOGETHER), 3);
+      const sectionSize2 = element.sectionSizeFactory(4, 0, 10, 0, 0);
+      assert.equal(sectionSize2(Section.RELATED_CHANGES), 4);
+      assert.equal(sectionSize2(Section.SAME_TOPIC), 10);
+    });
+    test('many sections', () => {
+      const sectionSize = element.sectionSizeFactory(20, 20, 3, 3, 3);
+      assert.equal(sectionSize(Section.RELATED_CHANGES), 3);
+      assert.equal(sectionSize(Section.SUBMITTED_TOGETHER), 3);
+      const sectionSize2 = element.sectionSizeFactory(4, 1, 10, 1, 1);
+      assert.equal(sectionSize2(Section.RELATED_CHANGES), 4);
+      assert.equal(sectionSize2(Section.SAME_TOPIC), 4);
+    });
+  });
+
+  suite('test first non-empty list', () => {
+    const relatedChangeInfo: RelatedChangesInfo = {
+      ...createRelatedChangesInfo(),
+      changes: [createRelatedChangeAndCommitInfo()],
+    };
+    const submittedTogether: SubmittedTogetherInfo = {
+      ...createSubmittedTogetherInfo(),
+      changes: [createChange()],
+    };
+
+    setup(() => {
+      element.change = createParsedChange();
+      element.patchNum = 1 as PatchSetNum;
+    });
+
+    test('first list', async () => {
+      stubRestApi('getRelatedChanges').returns(
+        Promise.resolve(relatedChangeInfo)
+      );
+      await element.reload();
+      const section = queryAndAssert<HTMLElement>(element, '#relatedChanges');
+      const relatedChanges = queryAndAssert<GrRelatedCollapse>(
+        section,
+        'gr-related-collapse'
+      );
+      assert.isTrue(relatedChanges!.classList.contains('first'));
+    });
+
+    test('first empty second non-empty', async () => {
+      stubRestApi('getRelatedChanges').returns(
+        Promise.resolve(createRelatedChangesInfo())
+      );
+      stubRestApi('getChangesSubmittedTogether').returns(
+        Promise.resolve(submittedTogether)
+      );
+      await element.reload();
+      const relatedChanges = queryAndAssert<GrRelatedCollapse>(
+        queryAndAssert<HTMLElement>(element, '#relatedChanges'),
+        'gr-related-collapse'
+      );
+      assert.isFalse(relatedChanges!.classList.contains('first'));
+      const submittedTogetherSection = queryAndAssert<GrRelatedCollapse>(
+        queryAndAssert<HTMLElement>(element, '#submittedTogether'),
+        'gr-related-collapse'
+      );
+      assert.isTrue(submittedTogetherSection!.classList.contains('first'));
+    });
+
+    test('first non-empty second empty third non-empty', async () => {
+      stubRestApi('getRelatedChanges').returns(
+        Promise.resolve(relatedChangeInfo)
+      );
+      stubRestApi('getChangesSubmittedTogether').returns(
+        Promise.resolve(createSubmittedTogetherInfo())
+      );
+      stubRestApi('getChangeCherryPicks').returns(
+        Promise.resolve([createChange()])
+      );
+      await element.reload();
+      const relatedChanges = queryAndAssert<GrRelatedCollapse>(
+        queryAndAssert<HTMLElement>(element, '#relatedChanges'),
+        'gr-related-collapse'
+      );
+      assert.isTrue(relatedChanges!.classList.contains('first'));
+      const submittedTogetherSection = queryAndAssert<GrRelatedCollapse>(
+        queryAndAssert<HTMLElement>(element, '#submittedTogether'),
+        'gr-related-collapse'
+      );
+      assert.isFalse(submittedTogetherSection!.classList.contains('first'));
+      const cherryPicks = queryAndAssert<GrRelatedCollapse>(
+        queryAndAssert<HTMLElement>(element, '#cherryPicks'),
+        'gr-related-collapse'
+      );
+      assert.isFalse(cherryPicks!.classList.contains('first'));
+    });
+  });
+
+  test('_changesEqual', () => {
+    const change1: ChangeInfo = {
+      ...createChange(),
+      change_id: '123' as ChangeId,
+      _number: 0 as NumericChangeId,
+    };
+    const change2: ChangeInfo = {
+      ...createChange(),
+      change_id: '456' as ChangeId,
+      _number: 1 as NumericChangeId,
+    };
+    const change3: ChangeInfo = {
+      ...createChange(),
+      change_id: '123' as ChangeId,
+      _number: 2 as NumericChangeId,
+    };
+    const change4: RelatedChangeAndCommitInfo = {
+      ...createRelatedChangeAndCommitInfo(),
+      change_id: '123' as ChangeId,
+      _change_number: 1 as NumericChangeId,
+    };
+
+    assert.isTrue(element._changesEqual(change1, change1));
+    assert.isFalse(element._changesEqual(change1, change2));
+    assert.isFalse(element._changesEqual(change1, change3));
+    assert.isTrue(element._changesEqual(change2, change4));
+  });
+
+  test('_getChangeNumber', () => {
+    const change1: ChangeInfo = {
+      ...createChange(),
+      change_id: '123' as ChangeId,
+      _number: 0 as NumericChangeId,
+    };
+    const change2: ChangeInfo = {
+      ...createChange(),
+      change_id: '456' as ChangeId,
+      _number: 1 as NumericChangeId,
+    };
+    assert.equal(element._getChangeNumber(change1), 0);
+    assert.equal(element._getChangeNumber(change2), 1);
+  });
+
+  suite('get conflicts tests', () => {
+    let element: GrRelatedChangesList;
+    let conflictsStub: SinonStubbedMember<RestApiService['getChangeConflicts']>;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      conflictsStub = stubRestApi('getChangeConflicts').returns(
+        Promise.resolve(undefined)
+      );
+    });
+
+    test('request conflicts if open and mergeable', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.mergeable = true;
+      element.reload();
+      assert.isTrue(conflictsStub.called);
+    });
+
+    test('does not request conflicts if closed and mergeable', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+
+    test('does not request conflicts if open and not mergeable', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.mergeable = false;
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+
+    test('doesnt request conflicts if closed and not mergeable', () => {
+      element.patchNum = 7 as PatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        change_id: '123' as ChangeId,
+        status: ChangeStatus.NEW,
+      };
+      element.mergeable = false;
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+  });
+
+  test('connected revisions', () => {
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      revisions: {
+        e3c6d60783bfdec9ebae7dcfec4662360433449e: createRevision(1),
+        '26e5e4c9c7ae31cbd876271cca281ce22b413997': createRevision(2),
+        bf7884d695296ca0c91702ba3e2bc8df0f69a907: createRevision(7),
+        b5fc49f2e67d1889d5275cac04ad3648f2ec7fe3: createRevision(5),
+        d6bcee67570859ccb684873a85cf50b1f0e96fda: createRevision(6),
+        cc960918a7f90388f4a9e05753d0f7b90ad44546: createRevision(3),
+        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': createRevision(4),
+      },
+    };
+    let patchNum = 7 as PatchSetNum;
+    let relatedChanges: RelatedChangeAndCommitInfo[] = [
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '2cebeedfb1e80f4b872d0a13ade529e70652c0c8'
+          ),
+          parents: [
+            {
+              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd' as CommitId,
+              subject: 'subject1',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
+          ),
+          parents: [
+            {
+              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb' as CommitId,
+              subject: 'subject2',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
+          ),
+          parents: [
+            {
+              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae' as CommitId,
+              subject: 'subject3',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'b0ccb183494a8e340b8725a2dc553967d61e6dae'
+          ),
+          parents: [
+            {
+              commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907' as CommitId,
+              subject: 'subject4',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'bf7884d695296ca0c91702ba3e2bc8df0f69a907'
+          ),
+          parents: [
+            {
+              commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce' as CommitId,
+              subject: 'subject5',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '613bc4f81741a559c6667ac08d71dcc3348f73ce'
+          ),
+          parents: [
+            {
+              commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75' as CommitId,
+              subject: 'subject6',
+            },
+          ],
+        },
+      },
+    ];
+
+    let connectedChanges = element._computeConnectedRevisions(
+      change,
+      patchNum,
+      relatedChanges
+    );
+    assert.deepEqual(connectedChanges, [
+      '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+      'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+      '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+      '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+      '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+    ]);
+
+    patchNum = 4 as PatchSetNum;
+    relatedChanges = [
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '2cebeedfb1e80f4b872d0a13ade529e70652c0c8'
+          ),
+          parents: [
+            {
+              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
+          ),
+          parents: [
+            {
+              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
+          ),
+          parents: [
+            {
+              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b'
+          ),
+          parents: [
+            {
+              commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6'
+          ),
+          parents: [
+            {
+              commit: 'af815dac54318826b7f1fa468acc76349ffc588e' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'af815dac54318826b7f1fa468acc76349ffc588e'
+          ),
+          parents: [
+            {
+              commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c' as CommitId,
+              subject: 'My parent commit',
+            },
+          ],
+        },
+      },
+    ];
+
+    connectedChanges = element._computeConnectedRevisions(
+      change,
+      patchNum,
+      relatedChanges
+    );
+    assert.deepEqual(connectedChanges, [
+      'af815dac54318826b7f1fa468acc76349ffc588e',
+      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+      'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
+    ]);
+  });
+
+  suite('gr-related-changes-list plugin tests', () => {
+    let element: GrRelatedChangesList;
+
+    setup(() => {
+      resetPlugins();
+      element = basicFixture.instantiate();
+    });
+
+    teardown(() => {
+      resetPlugins();
+    });
+
+    test('endpoint params', done => {
+      element.change = {...createParsedChange(), labels: {}};
+      interface RelatedChangesListGrEndpointDecorator
+        extends GrEndpointDecorator {
+        plugin: PluginApi;
+        change: ParsedChangeInfo;
+      }
+      let hookEl: RelatedChangesListGrEndpointDecorator;
+      let plugin: PluginApi;
+      pluginApi.install(
+        p => {
+          plugin = p;
+          plugin
+            .hook('related-changes-section')
+            .getLastAttached()
+            .then(el => (hookEl = el as RelatedChangesListGrEndpointDecorator));
+        },
+        '0.1',
+        'http://some/plugins/url1.js'
+      );
+      getPluginLoader().loadPlugins([]);
+      flush(() => {
+        assert.strictEqual(hookEl.plugin, plugin);
+        assert.strictEqual(hookEl.change, element.change);
+        done();
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
index 8a4b1f6..5f35fd3 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,11 +16,11 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import {resetPlugins} from '../../../test/test-utils.js';
+import {resetPlugins, stubRestApi} from '../../../test/test-utils.js';
 import './gr-reply-dialog.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+
 const basicFixture = fixtureFromElement('gr-reply-dialog');
 const pluginApi = _testOnly_initGerritPluginApi();
 
@@ -73,10 +73,7 @@
     changeNum = 42;
     patchNum = 1;
 
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getAccount() { return Promise.resolve({_account_id: 42}); },
-    });
+    stubRestApi('getAccount').returns(Promise.resolve({_account_id: 42}));
 
     element = basicFixture.instantiate();
     setupElement(element);
@@ -106,7 +103,7 @@
     assert.isTrue(sendStub.called);
   });
 
-  test('lgtm plugin', done => {
+  test('lgtm plugin', async () => {
     resetPlugins();
     pluginApi.install(plugin => {
       const replyApi = plugin.changeReply();
@@ -123,20 +120,17 @@
     element = basicFixture.instantiate();
     setupElement(element);
     getPluginLoader().loadPlugins([]);
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => {
-          flush(() => {
-            const textarea = element.$.textarea.getNativeTextarea();
-            textarea.value = 'LGTM';
-            textarea.dispatchEvent(new CustomEvent(
-                'input', {bubbles: true, composed: true}));
-            const labelScoreRows = dom(element.$.labelScores.root)
-                .querySelector('gr-label-score-row[name="Code-Review"]');
-            const selectedBtn = dom(labelScoreRows.root)
-                .querySelector('gr-button[data-value="+1"].iron-selected');
-            assert.isOk(selectedBtn);
-            done();
-          });
-        });
+    await getPluginLoader().awaitPluginsLoaded();
+    await flush();
+    const textarea = element.$.textarea.getNativeTextarea();
+    textarea.value = 'LGTM';
+    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');
+    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 7b34263..2cabf90 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -20,16 +20,11 @@
 import '../../shared/gr-textarea/gr-textarea';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-formatted-text/gr-formatted-text';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import '../../shared/gr-storage/gr-storage';
 import '../../shared/gr-account-list/gr-account-list';
 import '../gr-label-scores/gr-label-scores';
 import '../gr-thread-list/gr-thread-list';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-reply-dialog_html';
 import {
@@ -43,18 +38,13 @@
   ReviewerState,
   SpecialFilePath,
 } from '../../../constants/constants';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {fetchChangeUpdates} from '../../../utils/patch-set-util';
 import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {accountKey, removeServiceUsers} from '../../../utils/account-util';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import {TargetElement} from '../../plugins/gr-plugin-types';
+import {TargetElement} from '../../../api/plugin';
 import {customElement, observe, property} from '@polymer/decorators';
-import {
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {FixIronA11yAnnouncer} from '../../../types/types';
 import {
   AccountAddition,
@@ -64,7 +54,6 @@
   GroupObjectInput,
   RawAccountInput,
 } from '../../shared/gr-account-list/gr-account-list';
-import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
 import {
   AccountId,
   AccountInfo,
@@ -75,6 +64,7 @@
   GroupId,
   GroupInfo,
   isAccount,
+  isDetailedLabelInfo,
   isGroup,
   isReviewerAccountSuggestion,
   isReviewerGroupSuggestion,
@@ -99,17 +89,31 @@
 } from '@polymer/polymer/interfaces';
 import {
   areSetsEqual,
+  assertIsDefined,
   assertNever,
   containsAll,
 } from '../../../utils/common-util';
-import {CommentThread} from '../../../utils/comment-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 {GrStorage, StorageLocation} from '../../shared/gr-storage/gr-storage';
 import {isAttentionSetEnabled} from '../../../utils/attention-set-util';
-import {CODE_REVIEW, getMaxAccounts} from '../../../utils/label-util';
-import {isUnresolved} from '../../../utils/comment-util';
+import {
+  CODE_REVIEW,
+  getApprovalInfo,
+  getMaxAccounts,
+} from '../../../utils/label-util';
+import {pluralize} from '../../../utils/string-util';
+import {
+  fireAlert,
+  fireEvent,
+  fireIronAnnounce,
+  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';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -137,15 +141,14 @@
 };
 
 const ButtonTooltips = {
-  SAVE: 'Save but do not send notification or change review state',
+  SAVE: 'Send changes and comments as work in progress but do not start review',
   START_REVIEW: 'Mark as ready for review and send reply',
   SEND: 'Send reply',
+  DISABLED_COMMENT_EDITING: 'Save draft comments to enable send',
 };
 
 const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
 
-const SEND_REPLY_TIMING_LABEL = 'SendReply';
-
 interface PendingRemovals {
   CC: (AccountInfoInput | GroupInfoInput)[];
   REVIEWER: (AccountInfoInput | GroupInfoInput)[];
@@ -157,8 +160,6 @@
 
 export interface GrReplyDialog {
   $: {
-    restAPI: RestApiService & Element;
-    jsAPI: JsApiService & Element;
     reviewers: GrAccountList;
     ccs: GrAccountList;
     cancelButton: GrButton;
@@ -166,14 +167,11 @@
     labelScores: GrLabelScores;
     textarea: GrTextarea;
     reviewerConfirmationOverlay: GrOverlay;
-    storage: GrStorage;
   };
 }
 
 @customElement('gr-reply-dialog')
-export class GrReplyDialog extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrReplyDialog extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -358,8 +356,7 @@
     computed:
       '_computeSendButtonDisabled(canBeStarted, ' +
       'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' +
-      '_includeComments, disabled, _commentEditing, _attentionExpanded, ' +
-      '_currentAttentionSet, _newAttentionSet)',
+      '_includeComments, disabled, _commentEditing, change, _account)',
     observer: '_sendDisabledChanged',
   })
   _sendDisabled?: boolean;
@@ -373,6 +370,14 @@
   @property({type: Array, computed: '_computeAllReviewers(_reviewers.*)'})
   _allReviewers: (AccountInfo | GroupInfo)[] = [];
 
+  private readonly restApiService = appContext.restApiService;
+
+  private readonly storage = appContext.storageService;
+
+  private readonly jsAPI = appContext.jsApiService;
+
+  private storeTask?: DelayedTask;
+
   get keyBindings() {
     return {
       esc: '_handleEscKey',
@@ -380,8 +385,6 @@
     };
   }
 
-  _isPatchsetCommentsExperimentEnabled = false;
-
   constructor() {
     super();
     this.filterReviewerSuggestion = this._filterReviewerSuggestionGenerator(
@@ -391,8 +394,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     ((IronA11yAnnouncer as unknown) as FixIronA11yAnnouncer).requestAvailability();
     this._getAccount().then(account => {
       if (account) this._account = account;
@@ -421,16 +424,19 @@
   /** @override */
   ready() {
     super.ready();
-    this._isPatchsetCommentsExperimentEnabled = this.flagsService.isEnabled(
-      KnownExperimentId.PATCHSET_COMMENTS
-    );
-    this.$.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
+    this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    this.storeTask?.cancel();
+    super.disconnectedCallback();
   }
 
   open(focusTarget?: FocusTarget) {
-    if (!this.change) throw new Error('missing required change property');
+    assertIsDefined(this.change, 'change');
     this.knownLatestState = LatestPatchState.CHECKING;
-    fetchChangeUpdates(this.change, this.$.restAPI).then(result => {
+    fetchChangeUpdates(this.change, this.restApiService).then(result => {
       this.knownLatestState = result.isLatest
         ? LatestPatchState.LATEST
         : LatestPatchState.NOT_LATEST;
@@ -445,15 +451,10 @@
       // Otherwise, check for an unsaved draft in localstorage.
       this.draft = this._loadStoredDraft();
     }
-    if (this.$.restAPI.hasPendingDiffDrafts()) {
+    if (this.restApiService.hasPendingDiffDrafts()) {
       this._savingComments = true;
-      this.$.restAPI.awaitPendingDiffDrafts().then(() => {
-        this.dispatchEvent(
-          new CustomEvent('comment-refresh', {
-            composed: true,
-            bubbles: true,
-          })
-        );
+      this.restApiService.awaitPendingDiffDrafts().then(() => {
+        fireEvent(this, 'comment-refresh');
         this._savingComments = false;
       });
     }
@@ -548,13 +549,7 @@
             const moveTo = isReviewer ? 'reviewer' : 'CC';
             const id = account.name || account.email || key;
             const message = `${id} moved from ${moveFrom} to ${moveTo}.`;
-            this.dispatchEvent(
-              new CustomEvent('show-alert', {
-                detail: {message},
-                composed: true,
-                bubbles: true,
-              })
-            );
+            fireAlert(this, message);
           }
         }
       }
@@ -568,7 +563,7 @@
     for (const splice of indexSplices) {
       for (const account of splice.removed) {
         if (!this._reviewersPendingRemove[type]) {
-          console.error('Invalid type ' + type + ' for reviewer.');
+          this.reporting.error(new Error(`Invalid type ${type} for reviewer.`));
           return;
         }
         this._reviewersPendingRemove[type].push(account);
@@ -611,12 +606,12 @@
     account: AccountInfoInput | GroupInfoInput,
     type: ReviewerType
   ) {
-    if (!this.change) throw new Error('missing required change property');
+    assertIsDefined(this.change, 'change');
     if (account._pendingAdd || !isAccount(account)) {
       return;
     }
 
-    return this.$.restAPI
+    return this.restApiService
       .removeChangeReviewer(this.change._number, accountKey(account))
       .then((response?: Response) => {
         if (!response?.ok || !this.change) return;
@@ -647,7 +642,7 @@
     includeComments: boolean,
     startReview: boolean
   ): Promise<Map<AccountId | EmailAddress, boolean>> {
-    this.reporting.time(SEND_REPLY_TIMING_LABEL);
+    this.reporting.time(Timing.SEND_REPLY);
     const labels = this.$.labelScores.getLabelValues();
 
     const reviewInput: ReviewInput = {
@@ -686,17 +681,13 @@
     }
 
     if (this.draft) {
-      if (this._isPatchsetCommentsExperimentEnabled) {
-        const comment: CommentInput = {
-          message: this.draft,
-          unresolved: !this._isResolvedPatchsetLevelComment,
-        };
-        reviewInput.comments = {
-          [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [comment],
-        };
-      } else {
-        reviewInput.message = this.draft;
-      }
+      const comment: CommentInput = {
+        message: this.draft,
+        unresolved: !this._isResolvedPatchsetLevelComment,
+      };
+      reviewInput.comments = {
+        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [comment],
+      };
     }
 
     const accountAdditions = new Map<AccountId | EmailAddress, boolean>();
@@ -729,13 +720,7 @@
           return new Map<AccountId | EmailAddress, boolean>();
         }
         if (!response.ok) {
-          this.dispatchEvent(
-            new CustomEvent('server-error', {
-              detail: {response},
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fireServerError(response);
           return new Map<AccountId | EmailAddress, boolean>();
         }
 
@@ -747,7 +732,7 @@
             bubbles: false,
           })
         );
-        this.fire('iron-announce', {text: 'Reply sent'}, {bubbles: true});
+        fireIronAnnounce(this, 'Reply sent');
         return accountAdditions;
       })
       .then(result => {
@@ -767,13 +752,13 @@
     }
     if (section === FocusTarget.BODY) {
       const textarea = this.$.textarea;
-      textarea.async(() => textarea.getNativeTextarea().focus());
+      setTimeout(() => textarea.getNativeTextarea().focus());
     } else if (section === FocusTarget.REVIEWERS) {
       const reviewerEntry = this.$.reviewers.focusStart;
-      reviewerEntry.async(() => reviewerEntry.focus());
+      setTimeout(() => reviewerEntry.focus());
     } else if (section === FocusTarget.CCS) {
       const ccEntry = this.$.ccs.focusStart;
-      ccEntry.async(() => ccEntry.focus());
+      setTimeout(() => ccEntry.focus());
     }
   }
 
@@ -798,13 +783,14 @@
     return account._account_id === change.owner._account_id;
   }
 
-  _handle400Error(response?: Response | null) {
-    if (!response) throw new Error('Reponse is empty.');
+  _handle400Error(r?: Response | null) {
+    if (!r) throw new Error('Response is empty.');
+    let response: Response = r;
     // A call to _saveReview could fail with a server error if erroneous
     // reviewers were requested. This is signalled with a 400 Bad Request
-    // status. The default gr-rest-api-interface error handling would
-    // result in a large JSON response body being displayed to the user in
-    // the gr-error-manager toast.
+    // status. The default gr-rest-api error handling would result in a large
+    // JSON response body being displayed to the user in the gr-error-manager
+    // toast.
     //
     // We can modify the error handling behavior by passing this function
     // through to restAPI as a custom error handling function. Since we're
@@ -816,12 +802,12 @@
     // Using response.clone() here, because getResponseObject() and
     // potentially the generic error handler will want to call text() on the
     // response object, which can only be done once per object.
-    const jsonPromise = this.$.restAPI.getResponseObject(response.clone());
+    const jsonPromise = this.restApiService.getResponseObject(response.clone());
     return jsonPromise.then((parsed: ParsedJSON) => {
       const result = parsed as ReviewResult;
       // Only perform custom error handling for 400s and a parseable
       // ReviewResult response.
-      if (response && response.status === 400 && result && result.reviewers) {
+      if (response.status === 400 && result && result.reviewers) {
         const errors: string[] = [];
         const addReviewers = Object.values(result.reviewers);
         addReviewers.forEach(r => errors.push(r.error ?? 'no explanation'));
@@ -831,13 +817,7 @@
           text: () => Promise.resolve(errors.join(', ')),
         };
       }
-      this.dispatchEvent(
-        new CustomEvent('server-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireServerError(response);
     });
   }
 
@@ -847,13 +827,7 @@
 
   _computeDraftsTitle(draftCommentThreads?: CommentThread[]) {
     const total = draftCommentThreads ? draftCommentThreads.length : 0;
-    if (total === 0) {
-      return '';
-    }
-    if (total === 1) {
-      return '1 Draft';
-    }
-    return `${total} Drafts`;
+    return pluralize(total, 'Draft');
   }
 
   _computeMessagePlaceholder(canBeStarted: boolean) {
@@ -912,9 +886,7 @@
   _onAttentionExpandedChange() {
     // If the attention-detail section is expanded without dispatching this
     // event, then the dialog may expand beyond the screen's bottom border.
-    this.dispatchEvent(
-      new CustomEvent('iron-resize', {composed: true, bubbles: true})
-    );
+    fireEvent(this, 'iron-resize');
   }
 
   _showAttentionSummary(config?: ServerInfo, attentionExpanded?: boolean) {
@@ -1027,7 +999,8 @@
       const notIsReviewerAndHasDraftOrLabel = (r: AccountInfo) =>
         !(r._account_id === currentUser._account_id && (hasDrafts || hasVote));
       reviewers.base
-        .filter(r => r._pendingAdd && r._account_id)
+        .filter(r => r._account_id)
+        .filter(r => r._pendingAdd || (this.canBeStarted && isOwner))
         .filter(notIsReviewerAndHasDraftOrLabel)
         .forEach(r => newAttention.add(r._account_id!));
       // Add owner and uploader, if someone else replies.
@@ -1127,8 +1100,11 @@
     if (containsAll(currentAttentionSet, newAttentionSet)) {
       return 'No additions to the attention set.';
     }
-    console.error(
-      '_computeDoNotUpdateMessage() should not be called when users were added to the attention set.'
+    this.reporting.error(
+      new Error(
+        '_computeDoNotUpdateMessage()' +
+          'should not be called when users were added to the attention set.'
+      )
     );
     return '';
   }
@@ -1229,7 +1205,7 @@
   }
 
   _getAccount() {
-    return this.$.restAPI.getAccount();
+    return this.restApiService.getAccount();
   }
 
   _cancelTapHandler(e: Event) {
@@ -1238,7 +1214,7 @@
   }
 
   cancel() {
-    if (!this.change) throw new Error('missing required change property');
+    assertIsDefined(this.change, 'change');
     if (!this._owner) throw new Error('missing required _owner property');
     this.dispatchEvent(
       new CustomEvent('cancel', {
@@ -1275,13 +1251,7 @@
       return;
     }
     if (this._sendDisabled) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          bubbles: true,
-          composed: true,
-          detail: {message: EMPTY_REPLY_MESSAGE},
-        })
-      );
+      fireAlert(this, EMPTY_REPLY_MESSAGE);
       return;
     }
     return this.send(this._includeComments, this.canBeStarted)
@@ -1300,9 +1270,9 @@
   }
 
   _saveReview(review: ReviewInput, errFn?: ErrorCallback) {
-    if (!this.change) throw new Error('missing required change property');
-    if (!this.patchNum) throw new Error('missing required patchNum property');
-    return this.$.restAPI.saveChangeReview(
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchNum, 'patchNum');
+    return this.restApiService.saveChangeReview(
       this.change._number,
       this.patchNum,
       review,
@@ -1331,7 +1301,9 @@
       this._focusOn(FocusTarget.REVIEWERS);
       return;
     }
-    console.error('_confirmPendingReviewer called without pending confirm');
+    this.reporting.error(
+      new Error('_confirmPendingReviewer called without pending confirm')
+    );
   }
 
   _cancelPendingReviewer() {
@@ -1345,7 +1317,7 @@
   }
 
   _getStorageLocation(): StorageLocation {
-    if (!this.change) throw new Error('missing required change property');
+    assertIsDefined(this.change, 'change');
     return {
       changeNum: this.change._number,
       patchNum: '@change',
@@ -1354,7 +1326,7 @@
   }
 
   _loadStoredDraft() {
-    const draft = this.$.storage.getDraftComment(this._getStorageLocation());
+    const draft = this.storage.getDraftComment(this._getStorageLocation());
     return draft?.message ?? '';
   }
 
@@ -1367,18 +1339,15 @@
   }
 
   _draftChanged(newDraft: string, oldDraft?: string) {
-    this.debounce(
-      'store',
+    this.storeTask = debounce(
+      this.storeTask,
       () => {
         if (!newDraft.length && oldDraft) {
           // If the draft has been modified to be empty, then erase the storage
           // entry.
-          this.$.storage.eraseDraftComment(this._getStorageLocation());
+          this.storage.eraseDraftComment(this._getStorageLocation());
         } else if (newDraft.length) {
-          this.$.storage.setDraftComment(
-            this._getStorageLocation(),
-            this.draft
-          );
+          this.storage.setDraftComment(this._getStorageLocation(), this.draft);
         }
       },
       STORAGE_DEBOUNCE_INTERVAL_MS
@@ -1386,12 +1355,7 @@
   }
 
   _handleHeightChanged() {
-    this.dispatchEvent(
-      new CustomEvent('autogrow', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'autogrow');
   }
 
   _handleLabelsChanged() {
@@ -1420,7 +1384,10 @@
       : ButtonLabels.SEND;
   }
 
-  _computeSendButtonTooltip(canBeStarted: boolean) {
+  _computeSendButtonTooltip(canBeStarted?: boolean, commentEditing?: boolean) {
+    if (commentEditing) {
+      return ButtonTooltips.DISABLED_COMMENT_EDITING;
+    }
     return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND;
   }
 
@@ -1436,7 +1403,9 @@
     labelsChanged?: boolean,
     includeComments?: boolean,
     disabled?: boolean,
-    commentEditing?: boolean
+    commentEditing?: boolean,
+    change?: ChangeInfo,
+    account?: AccountInfo
   ) {
     if (
       canBeStarted === undefined ||
@@ -1446,7 +1415,9 @@
       labelsChanged === undefined ||
       includeComments === undefined ||
       disabled === undefined ||
-      commentEditing === undefined
+      commentEditing === undefined ||
+      change?.labels === undefined ||
+      account === undefined
     ) {
       return undefined;
     }
@@ -1456,8 +1427,14 @@
     if (canBeStarted === true) {
       return false;
     }
+    const existingVote = Object.values(change.labels).some(
+      label => isDetailedLabelInfo(label) && getApprovalInfo(label, account)
+    );
+    const revotingOrNewVote = labelsChanged || existingVote;
     const hasDrafts = includeComments && draftCommentThreads.length;
-    return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged;
+    return (
+      !hasDrafts && !text.length && !reviewersMutated && !revotingOrNewVote
+    );
   }
 
   _computePatchSetWarning(patchNum?: PatchSetNum, labelsChanged?: boolean) {
@@ -1478,7 +1455,7 @@
 
   _getReviewerSuggestionsProvider(change: ChangeInfo) {
     const provider = GrReviewerSuggestionsProvider.create(
-      this.$.restAPI,
+      this.restApiService,
       change._number,
       SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER
     );
@@ -1488,7 +1465,7 @@
 
   _getCcSuggestionsProvider(change: ChangeInfo) {
     const provider = GrReviewerSuggestionsProvider.create(
-      this.$.restAPI,
+      this.restApiService,
       change._number,
       SUGGESTIONS_PROVIDERS_USERS_TYPES.CC
     );
@@ -1496,20 +1473,6 @@
     return provider;
   }
 
-  _onThreadListModified() {
-    // TODO(taoalpha): this won't propogate the changes to the files
-    // should consider replacing this with either top level events
-    // or gerrit level events
-
-    // emit the event so change-view can also get updated with latest changes
-    this.dispatchEvent(
-      new CustomEvent('comment-refresh', {
-        composed: true,
-        bubbles: true,
-      })
-    );
-  }
-
   reportAttentionSetChanges(
     modified: boolean,
     addedSet?: AttentionSetInput[],
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index c56a5c9..f458994 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -176,13 +176,9 @@
          great solution. */
       line-height: 26px;
     }
-    .attention-detail .peopleList .accountList {
-      display: flex;
-      flex-wrap: wrap;
-    }
     .attentionSummary gr-account-label,
     .attention-detail gr-account-label {
-      --account-max-length: 150px;
+      --account-max-length: 120px;
       display: inline-block;
       padding: var(--spacing-xs) var(--spacing-m);
       user-select: none;
@@ -193,8 +189,11 @@
       line-height: var(--line-height-normal);
       vertical-align: top;
     }
+    .attention-detail .peopleListValues {
+      line-height: calc(var(--line-height-normal) + 10px);
+    }
     .attention-detail gr-account-label {
-      vertical-align: baseline;
+      line-height: var(--line-height-normal);
     }
     .attentionSummary gr-account-label:focus,
     .attention-detail gr-account-label:focus {
@@ -271,9 +270,7 @@
             [[_pendingConfirmationDetails.group.name]]
           </span>
           has
-          <span class="groupSize">
-            [[_pendingConfirmationDetails.count]]
-          </span>
+          <span class="groupSize"> [[_pendingConfirmationDetails.count]] </span>
           members.
           <br />
           Are you sure you want to add them all?
@@ -303,16 +300,14 @@
       </gr-endpoint-decorator>
     </section>
     <section class="previewContainer">
-      <template is="dom-if" if="[[_isPatchsetCommentsExperimentEnabled]]">
-        <label>
-          <input
-            id="resolvedPatchsetLevelCommentCheckbox"
-            type="checkbox"
-            checked="{{_isResolvedPatchsetLevelComment::change}}"
-          />
-          Resolved
-        </label>
-      </template>
+      <label>
+        <input
+          id="resolvedPatchsetLevelCommentCheckbox"
+          type="checkbox"
+          checked="{{_isResolvedPatchsetLevelComment::change}}"
+        />
+        Resolved
+      </label>
       <label class="preview-formatting">
         <input type="checkbox" checked="{{_previewFormatting::change}}" />
         Preview formatting
@@ -357,7 +352,6 @@
         change-num="[[change._number]]"
         logged-in="true"
         hide-toggle-buttons=""
-        on-thread-list-modified="_onThreadListModified"
       >
       </gr-thread-list>
       <span
@@ -396,8 +390,8 @@
                 <gr-account-label
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+                  selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                  deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
                   hide-hovercard=""
                   on-click="_handleAttentionClick"
                 ></gr-account-label>
@@ -423,15 +417,6 @@
           </div>
           <div>
             <a
-              href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set"
-              target="_blank"
-            >
-              <iron-icon
-                icon="gr-icons:bug"
-                title="report a problem"
-              ></iron-icon>
-            </a>
-            <a
               href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
               target="_blank"
             >
@@ -454,15 +439,6 @@
           <div></div>
           <div>
             <a
-              href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set"
-              target="_blank"
-            >
-              <iron-icon
-                icon="gr-icons:bug"
-                title="report a problem"
-              ></iron-icon>
-            </a>
-            <a
               href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
               target="_blank"
             >
@@ -481,12 +457,12 @@
         </div>
         <div class="peopleList">
           <div class="peopleListLabel">Owner</div>
-          <div>
+          <div class="peopleListValues">
             <gr-account-label
               account="[[_owner]]"
               force-attention="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
-              selected$="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
-              deselected$="[[!_computeHasNewAttention(_owner, _newAttentionSet)]]"
+              selected="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
+              deselected="[[!_computeHasNewAttention(_owner, _newAttentionSet)]]"
               hide-hovercard=""
               on-click="_handleAttentionClick"
             >
@@ -496,12 +472,12 @@
         <template is="dom-if" if="[[_uploader]]">
           <div class="peopleList">
             <div class="peopleListLabel">Uploader</div>
-            <div>
+            <div class="peopleListValues">
               <gr-account-label
                 account="[[_uploader]]"
                 force-attention="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                selected$="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                deselected$="[[!_computeHasNewAttention(_uploader, _newAttentionSet)]]"
+                selected="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
+                deselected="[[!_computeHasNewAttention(_uploader, _newAttentionSet)]]"
                 hide-hovercard=""
                 on-click="_handleAttentionClick"
               >
@@ -511,7 +487,7 @@
         </template>
         <div class="peopleList">
           <div class="peopleListLabel">Reviewers</div>
-          <div>
+          <div class="peopleListValues">
             <template
               is="dom-repeat"
               items="[[_removeServiceUsers(_reviewers, _newAttentionSet)]]"
@@ -520,8 +496,8 @@
               <gr-account-label
                 account="[[account]]"
                 force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+                selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
                 hide-hovercard=""
                 on-click="_handleAttentionClick"
               >
@@ -532,7 +508,7 @@
         <template is="dom-if" if="[[_attentionCcsCount]]">
           <div class="peopleList">
             <div class="peopleListLabel">CC</div>
-            <div>
+            <div class="peopleListValues">
               <template
                 is="dom-repeat"
                 items="[[_removeServiceUsers(_ccs, _newAttentionSet)]]"
@@ -541,8 +517,8 @@
                 <gr-account-label
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+                  selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                  deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
                   hide-hovercard=""
                   on-click="_handleAttentionClick"
                 >
@@ -599,7 +575,7 @@
               has-tooltip=""
               title="[[_saveTooltip]]"
               on-click="_saveClickHandler"
-              >Save</gr-button
+              >Send As WIP</gr-button
             >
           </template>
           <gr-button
@@ -608,7 +584,7 @@
             disabled="[[_sendDisabled]]"
             class="action send"
             has-tooltip=""
-            title$="[[_computeSendButtonTooltip(canBeStarted)]]"
+            title$="[[_computeSendButtonTooltip(canBeStarted, _commentEditing)]]"
             on-click="_sendTapHandler"
             >[[_sendButtonLabel]]</gr-button
           >
@@ -616,7 +592,4 @@
       </section>
     </div>
   </div>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
index b612a57..c1e2564 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -18,9 +18,14 @@
 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} from '../../../test/test-utils.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');
 
@@ -60,12 +65,9 @@
     changeNum = 42;
     patchNum = 1;
 
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getAccount() { return Promise.resolve({}); },
-      getChange() { return Promise.resolve({}); },
-      getChangeSuggestedReviewers() { return Promise.resolve([]); },
-    });
+    stubRestApi('getAccount').returns(Promise.resolve({}));
+    stubRestApi('getChange').returns(Promise.resolve({}));
+    stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
 
     sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
 
@@ -111,10 +113,9 @@
       ],
     };
 
-    getDraftCommentStub = sinon.stub(element.$.storage, 'getDraftComment');
-    setDraftCommentStub = sinon.stub(element.$.storage, 'setDraftComment');
-    eraseDraftCommentStub = sinon.stub(element.$.storage,
-        'eraseDraftComment');
+    getDraftCommentStub = stubStorage('getDraftComment');
+    setDraftCommentStub = stubStorage('setDraftComment');
+    eraseDraftCommentStub = stubStorage('eraseDraftComment');
 
     // sinon.stub(patchSetUtilMockProxy, 'fetchChangeUpdates')
     //     .returns(Promise.resolve({isLatest: true}));
@@ -130,8 +131,7 @@
         .callsFake(review => new Promise((resolve, reject) => {
           try {
             const result = jsonResponseProducer(review) || {};
-            const resultStr =
-            element.$.restAPI.JSON_PREFIX + JSON.stringify(result);
+            const resultStr = JSON_PREFIX + JSON.stringify(result);
             resolve({
               ok: true,
               text() {
@@ -313,6 +313,39 @@
     assert.sameMembers([...element._newAttentionSet], [2, 5]);
   });
 
+  test('computeNewAttention when sending wip change for review', () => {
+    const reviewers = {base: [
+      {_account_id: 2},
+      {_account_id: 3},
+    ]};
+    const change = {
+      owner: {_account_id: 1},
+      status: 'NEW',
+      attention_set: {},
+    };
+    element.change = change;
+    element._reviewers = reviewers.base;
+    flush();
+
+    // For an active change there is no reason to add anyone to the set.
+    let user = {_account_id: 1};
+    element._computeNewAttention(user, reviewers, [], change, [], false);
+    assert.sameMembers([...element._newAttentionSet], []);
+
+    // If the change is "work in progress" and the owner sends a reply, then
+    // add all reviewers.
+    element.canBeStarted = true;
+    flush();
+    user = {_account_id: 1};
+    element._computeNewAttention(user, reviewers, [], change, [], false);
+    assert.sameMembers([...element._newAttentionSet], [2, 3]);
+
+    // ... but not when someone else replies.
+    user = {_account_id: 4};
+    element._computeNewAttention(user, reviewers, [], change, [], false);
+    assert.sameMembers([...element._newAttentionSet], []);
+  });
+
   test('computeNewAttentionAccounts', () => {
     element._reviewers = [
       {_account_id: 123, display_name: 'Ernie'},
@@ -576,7 +609,7 @@
     return false;
   }
 
-  function testConfirmationDialog(done, cc) {
+  async function testConfirmationDialog(cc) {
     const yesButton = element
         .shadowRoot
         .querySelector('.reviewerConfirmationButtons gr-button:first-child');
@@ -618,95 +651,90 @@
           element._pendingConfirmationDetails);
     }
 
-    observer
-        .then(() => {
-          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
-          return observer;
-        }).then(() => {
-          assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
+    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
 
-          // We should be focused on account entry input.
-          assert.isTrue(
-              isFocusInsideElement(
-                  element.$.reviewers.$.entry.$.input.$.input
-              )
-          );
+    await observer;
+    assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
 
-          // No reviewer/CC should have been added.
-          assert.equal(element.$.ccs.additions().length, 0);
-          assert.equal(element.$.reviewers.additions().length, 0);
+    // We should be focused on account entry input.
+    assert.isTrue(
+        isFocusInsideElement(
+            element.$.reviewers.$.entry.$.input.$.input
+        )
+    );
 
-          // Reopen confirmation dialog.
-          observer = overlayObserver('opened');
-          if (cc) {
-            element._ccPendingConfirmation = {
-              group,
-              count: 10,
-            };
-          } else {
-            element._reviewerPendingConfirmation = {
-              group,
-              count: 10,
-            };
-          }
-          return observer;
-        })
-        .then(() => {
-          assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
-          observer = overlayObserver('closed');
-          MockInteractions.tap(yesButton); // Confirm the group.
-          return observer;
-        })
-        .then(() => {
-          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,
-                  },
-                },
-              ]);
+    // No reviewer/CC should have been added.
+    assert.equal(element.$.ccs.additions().length, 0);
+    assert.equal(element.$.reviewers.additions().length, 0);
 
-          // 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
-                )
-            );
-          }
-        })
-        .then(done);
+    // 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', done => {
-    testConfirmationDialog(done, true);
+  test('cc confirmation', async () => {
+    testConfirmationDialog(true);
   });
 
-  test('reviewer confirmation', done => {
-    testConfirmationDialog(done, false);
+  test('reviewer confirmation', async () => {
+    testConfirmationDialog(false);
   });
 
   test('_getStorageLocation', () => {
@@ -768,50 +796,57 @@
     const location = element._getStorageLocation();
 
     element.draft = firstEdit;
-    element.flushDebouncer('store');
+    element.storeTask.flush();
 
     assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
 
     element.draft = '';
-    element.flushDebouncer('store');
+    element.storeTask.flush();
 
     assert.isTrue(eraseDraftCommentStub.calledWith(location));
   });
 
-  test('400 converts to human-readable server-error', async () => {
-    sinon.stub(window, 'fetch').callsFake(() => {
-      const text = '....{"reviewers":{"id1":{"error":"human readable"}}}';
-      return Promise.resolve(cloneableResponse(400, text));
-    });
+  test('400 converts to human-readable server-error', done => {
+    stubRestApi('saveChangeReview').callsFake(
+        (changeNum, patchNum, review, errFn) => {
+          errFn(cloneableResponse(
+              400,
+              '....{"reviewers":{"id1":{"error":"human readable"}}}'
+          ));
+          return Promise.resolve(undefined);
+        }
+    );
 
-    let resolver;
-    const promise = new Promise(r => resolver = r);
-    element.addEventListener('server-error', resolver);
+    const listener = event => {
+      if (event.target !== document) return;
+      event.detail.response.text().then(body => {
+        if (body === 'human readable') {
+          done();
+        }
+      });
+    };
+    addListenerForTest(document, 'server-error', listener);
 
-    await flush();
-    element.send();
-
-    const event = await promise;
-    assert.equal(event.target, element);
-    const text = await event.detail.response.text();
-    assert.equal(text, 'human readable');
+    flush(() => { element.send(); });
   });
 
   test('non-json 400 is treated as a normal server-error', done => {
-    sinon.stub(window, 'fetch').callsFake(() => {
-      const text = 'Comment validation error!';
-      return Promise.resolve(cloneableResponse(400, text));
-    });
+    stubRestApi('saveChangeReview').callsFake(
+        (changeNum, patchNum, review, errFn) => {
+          errFn(cloneableResponse(400, 'Comment validation error!'));
+          return Promise.resolve(undefined);
+        }
+    );
 
-    element.addEventListener('server-error', event => {
-      if (event.target !== element) {
-        return;
-      }
+    const listener = event => {
+      if (event.target !== document) return;
       event.detail.response.text().then(body => {
-        assert.equal(body, 'Comment validation error!');
-        done();
+        if (body === 'Comment validation error!') {
+          done();
+        }
       });
-    });
+    };
+    addListenerForTest(document, 'server-error', listener);
 
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
@@ -847,42 +882,37 @@
     assert.isFalse(filter({group: cc2}));
   });
 
-  test('_focusOn', () => {
+  test('_focusOn', async () => {
     sinon.spy(element, '_chooseFocusTarget');
-    flush();
-    const textareaStub = sinon.stub(element.$.textarea, 'async');
-    const reviewerEntryStub = sinon.stub(element.$.reviewers.focusStart,
-        'async');
-    const ccStub = sinon.stub(element.$.ccs.focusStart, 'async');
     element._focusOn();
+    await flush();
     assert.equal(element._chooseFocusTarget.callCount, 1);
-    assert.deepEqual(textareaStub.callCount, 1);
-    assert.deepEqual(reviewerEntryStub.callCount, 0);
-    assert.deepEqual(ccStub.callCount, 0);
+    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.deepEqual(textareaStub.callCount, 2);
-    assert.deepEqual(reviewerEntryStub.callCount, 0);
-    assert.deepEqual(ccStub.callCount, 0);
+    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.deepEqual(textareaStub.callCount, 3);
-    assert.deepEqual(reviewerEntryStub.callCount, 0);
-    assert.deepEqual(ccStub.callCount, 0);
+    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.deepEqual(textareaStub.callCount, 3);
-    assert.deepEqual(reviewerEntryStub.callCount, 1);
-    assert.deepEqual(ccStub.callCount, 0);
+    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.deepEqual(textareaStub.callCount, 3);
-    assert.deepEqual(reviewerEntryStub.callCount, 1);
-    assert.deepEqual(ccStub.callCount, 1);
+    assert.equal(element.shadowRoot.activeElement.tagName, 'GR-ACCOUNT-LIST');
+    assert.equal(element.shadowRoot.activeElement.id, 'ccs');
   });
 
   test('_chooseFocusTarget', () => {
@@ -960,8 +990,8 @@
       };
     };
     const checkObjEmpty = function(obj) {
-      for (const prop in obj) {
-        if (obj.hasOwnProperty(prop) && obj[prop].length) { return false; }
+      for (const prop of Object.keys(obj)) {
+        if (obj[prop].length) { return false; }
       }
       return true;
     };
@@ -977,7 +1007,7 @@
   });
 
   test('_removeAccount', done => {
-    sinon.stub(element.$.restAPI, 'removeChangeReviewer')
+    stubRestApi('removeChangeReviewer')
         .returns(Promise.resolve({ok: true}));
     const arr = [makeAccount(), makeAccount()];
     element.change.reviewers = {
@@ -1235,7 +1265,7 @@
         'Send and Start review');
   });
 
-  test('_handle400Error reviewrs and CCs', done => {
+  test('_handle400Error reviewers and CCs', done => {
     const error1 = 'error 1';
     const error2 = 'error 2';
     const error3 = 'error 3';
@@ -1255,12 +1285,13 @@
         },
       },
     });
-    element.addEventListener('server-error', e => {
+    const listener = e => {
       e.detail.response.text().then(text => {
         assert.equal(text, [error1, error2, error3].join(', '));
         done();
       });
-    });
+    };
+    addListenerForTest(document, 'server-error', listener);
     element._handle400Error(cloneableResponse(400, text));
   });
 
@@ -1278,36 +1309,6 @@
     });
   });
 
-  suite('post review API', () => {
-    let startReviewStub;
-
-    setup(() => {
-      startReviewStub = sinon.stub(
-          element.$.restAPI,
-          'startReview')
-          .callsFake(() => Promise.resolve());
-    });
-
-    test('ready property in review input on start review', () => {
-      stubSaveReview(review => {
-        assert.isTrue(review.ready);
-        return {ready: true};
-      });
-      return element.send(true, true).then(() => {
-        assert.isFalse(startReviewStub.called);
-      });
-    });
-
-    test('no ready property in review input on save review', () => {
-      stubSaveReview(review => {
-        assert.isUndefined(review.ready);
-      });
-      return element.send(true, false).then(() => {
-        assert.isFalse(startReviewStub.called);
-      });
-    });
-  });
-
   suite('start review and save buttons', () => {
     let sendStub;
 
@@ -1366,38 +1367,36 @@
     });
 
     suite('pending diff drafts?', () => {
-      test('yes', () => {
+      test('yes', async () => {
         const promise = mockPromise();
-        const refreshHandler = sinon.stub();
+        const refreshSpy = sinon.spy();
+        element.addEventListener('comment-refresh', refreshSpy);
+        stubRestApi('hasPendingDiffDrafts').returns(true);
+        stubRestApi('awaitPendingDiffDrafts').returns(promise);
 
-        element.addEventListener('comment-refresh', refreshHandler);
-        sinon.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(true);
-        element.$.restAPI._pendingRequests.sendDiffDraft = [promise];
         element.open();
 
-        assert.isFalse(refreshHandler.called);
+        assert.isFalse(refreshSpy.called);
         assert.isTrue(element._savingComments);
 
         promise.resolve();
+        await flush();
 
-        return element.$.restAPI.awaitPendingDiffDrafts().then(() => {
-          assert.isTrue(refreshHandler.called);
-          assert.isFalse(element._savingComments);
-        });
+        assert.isTrue(refreshSpy.called);
+        assert.isFalse(element._savingComments);
       });
 
       test('no', () => {
-        sinon.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(false);
+        stubRestApi('hasPendingDiffDrafts').returns(false);
         element.open();
-        assert.notOk(element._savingComments);
+        assert.isFalse(element._savingComments);
       });
     });
   });
 
   test('_computeSendButtonDisabled_canBeStarted', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
     // Mock canBeStarted
-    assert.isFalse(fn(
+    assert.isFalse(element._computeSendButtonDisabled(
         /* canBeStarted= */ true,
         /* draftCommentThreads= */ [],
         /* text= */ '',
@@ -1405,14 +1404,15 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
     ));
   });
 
   test('_computeSendButtonDisabled_allFalse', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
     // Mock everything false
-    assert.isTrue(fn(
+    assert.isTrue(element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ [],
         /* text= */ '',
@@ -1420,14 +1420,15 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
     ));
   });
 
   test('_computeSendButtonDisabled_draftCommentsSend', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
     // Mock nonempty comment draft array, with sending comments.
-    assert.isFalse(fn(
+    assert.isFalse(element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
         /* text= */ '',
@@ -1435,14 +1436,15 @@
         /* labelsChanged= */ false,
         /* includeComments= */ true,
         /* disabled= */ false,
-        /* commentEditing= */ false
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
     ));
   });
 
   test('_computeSendButtonDisabled_draftCommentsDoNotSend', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
     // Mock nonempty comment draft array, without sending comments.
-    assert.isTrue(fn(
+    assert.isTrue(element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
         /* text= */ '',
@@ -1450,14 +1452,15 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
     ));
   });
 
   test('_computeSendButtonDisabled_changeMessage', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
     // Mock nonempty change message.
-    assert.isFalse(fn(
+    assert.isFalse(element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ {},
         /* text= */ 'test',
@@ -1465,14 +1468,15 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
     ));
   });
 
   test('_computeSendButtonDisabled_reviewersChanged', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
     // Mock reviewers mutated.
-    assert.isFalse(fn(
+    assert.isFalse(element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ {},
         /* text= */ '',
@@ -1480,14 +1484,15 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
     ));
   });
 
   test('_computeSendButtonDisabled_labelsChanged', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
     // Mock labels changed.
-    assert.isFalse(fn(
+    assert.isFalse(element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ {},
         /* text= */ '',
@@ -1495,14 +1500,15 @@
         /* labelsChanged= */ true,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
     ));
   });
 
   test('_computeSendButtonDisabled_dialogDisabled', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
     // Whole dialog is disabled.
-    assert.isTrue(fn(
+    assert.isTrue(element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ {},
         /* text= */ '',
@@ -1510,27 +1516,40 @@
         /* labelsChanged= */ true,
         /* includeComments= */ false,
         /* disabled= */ true,
-        /* commentEditing= */ false
-    ));
-    assert.isTrue(fn(
-        /* buttonLabel= */ 'Send',
-        /* draftCommentThreads= */ {},
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ true,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ true
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
     ));
   });
 
-  test('_submit blocked when no mutations exist', () => {
+  test('_computeSendButtonDisabled_existingVote', async () => {
+    const account = createAccountWithId();
+    element.change.labels[CODE_REVIEW].all = [account];
+    await flush();
+
+    // User has already voted.
+    assert.isFalse(element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ {},
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ account
+    ));
+  });
+
+  test('_submit blocked when no mutations exist', async () => {
     const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
     // Stub the below function to avoid side effects from the send promise
     // resolving.
     sinon.stub(element, '_purgeReviewersPendingRemove');
+    element.account = makeAccount();
     element.draftCommentThreads = [];
-    flush();
+    await flush();
 
     MockInteractions.tap(element.shadowRoot
         .querySelector('gr-button.send'));
@@ -1539,21 +1558,26 @@
     element.draftCommentThreads = [{comments: [
       {__draft: true, path: 'test', line: 1, patch_set: 1},
     ]}];
-    flush();
+    await flush();
 
     MockInteractions.tap(element.shadowRoot
         .querySelector('gr-button.send'));
     assert.isTrue(sendStub.called);
   });
 
-  test('getFocusStops', () => {
+  test('getFocusStops', async () => {
     // Setting draftCommentThreads to an empty object causes _sendDisabled to be
     // computed to false.
     element.draftCommentThreads = [];
+    element.account = makeAccount();
+    await flush();
+
     assert.equal(element.getFocusStops().end, element.$.cancelButton);
-    element.draftCommentThreads = [{comments: [
-      {__draft: true, path: 'test', line: 1, patch_set: 1},
-    ]}];
+    element.draftCommentThreads = [
+      {comments: [{__draft: true, path: 'test', line: 1, patch_set: 1}]},
+    ];
+    await flush();
+
     assert.equal(element.getFocusStops().end, element.$.sendButton);
   });
 
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 9c3fa42..1c70350 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,14 +16,11 @@
  */
 import '../../shared/gr-account-chip/gr-account-chip';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-reviewer-list_html';
-import {isServiceUser} from '../../../utils/account-util';
+import {isSelf, isServiceUser} from '../../../utils/account-util';
 import {hasAttention} from '../../../utils/attention-set-util';
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {
@@ -36,24 +33,17 @@
   AccountId,
   DetailedLabelInfo,
   EmailAddress,
+  AccountDetailInfo,
 } from '../../../types/common';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {isRemovableReviewer} from '../../../utils/change-util';
 import {ReviewerState} from '../../../constants/constants';
-
-export interface GrReviewerList {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
 
 @customElement('gr-reviewer-list')
-export class GrReviewerList extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrReviewerList extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -68,6 +58,9 @@
   change?: ChangeInfo;
 
   @property({type: Object})
+  account?: AccountDetailInfo;
+
+  @property({type: Object})
   serverConfig?: ServerInfo;
 
   @property({type: Boolean, reflectToAttribute: true})
@@ -94,6 +87,8 @@
   @property({type: Object})
   _xhrPromise?: Promise<Response | undefined>;
 
+  private readonly restApiService = appContext.restApiService;
+
   @computed('ccsOnly')
   get _addLabel() {
     return this.ccsOnly ? 'Add CC' : 'Add reviewer';
@@ -222,7 +217,7 @@
     }
     let result: AccountInfo[] = [];
     const reviewers = changeRecord.base;
-    for (const key in reviewers) {
+    for (const key of Object.keys(reviewers)) {
       if (this.reviewersOnly && key !== 'REVIEWER') {
         continue;
       }
@@ -236,10 +231,15 @@
     this._reviewers = result
       .filter(reviewer => reviewer._account_id !== owner._account_id)
       // Sort order:
-      // 1. Human users in the attention set.
-      // 2. Other human users.
-      // 3. Service users.
+      // 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;
@@ -323,6 +323,6 @@
 
   _removeReviewer(id: AccountId | EmailAddress): Promise<Response | undefined> {
     if (!this.change) return Promise.resolve(undefined);
-    return this.$.restAPI.removeChangeReviewer(this.change._number, id);
+    return this.restApiService.removeChangeReviewer(this.change._number, id);
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
index 616a7db..25d8517 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
@@ -27,13 +27,34 @@
     }
     .container {
       display: block;
+      /* line-height-normal for the chips, 2px for the chip border, spacing-s
+         for the gap between lines, negative bottom margin for eliminating the
+         gap after the last line */
+      line-height: calc(var(--line-height-normal) + 2px + var(--spacing-s));
+      margin-bottom: calc(0px - var(--spacing-s));
+    }
+    .addReviewer iron-icon {
+      color: inherit;
+      --iron-icon-height: 18px;
+      --iron-icon-width: 18px;
+    }
+    .controlsContainer {
+      display: inline-block;
+    }
+    gr-button.addReviewer {
+      --padding: 1px 4px;
+      vertical-align: top;
+      top: 1px;
     }
     gr-button {
+      line-height: var(--line-height-normal);
       --gr-button: {
         padding: 0px 0px;
       }
     }
     gr-account-chip {
+      line-height: var(--line-height-normal);
+      vertical-align: top;
       display: inline-block;
     }
   </style>
@@ -51,6 +72,16 @@
         >
         </gr-account-chip>
       </template>
+      <div class="controlsContainer" hidden$="[[!mutable]]">
+        <gr-button
+          link=""
+          id="addReviewer"
+          class="addReviewer"
+          on-click="_handleAddTap"
+          title="[[_addLabel]]"
+          ><iron-icon icon="gr-icons:edit"></iron-icon
+        ></gr-button>
+      </div>
     </div>
     <gr-button
       class="hiddenReviewers"
@@ -59,15 +90,5 @@
       on-click="_handleViewAll"
       >and [[_hiddenReviewerCount]] more</gr-button
     >
-    <div class="controlsContainer" hidden$="[[!mutable]]">
-      <gr-button
-        link=""
-        id="addReviewer"
-        class="addReviewer"
-        on-click="_handleAddTap"
-        >[[_addLabel]]</gr-button
-      >
-    </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
index d29abfc..ddfe537 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-reviewer-list.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-reviewer-list');
 
@@ -27,15 +28,11 @@
     element = basicFixture.instantiate();
     element.serverConfig = {};
 
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      removeChangeReviewer() {
-        return Promise.resolve({ok: true});
-      },
-    });
+    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
   });
 
   test('controls hidden on immutable element', () => {
+    flush();
     element.mutable = false;
     assert.isTrue(element.shadowRoot
         .querySelector('.controlsContainer').hasAttribute('hidden'));
@@ -48,6 +45,7 @@
     element.addEventListener('show-reply-dialog', () => {
       done();
     });
+    flush();
     MockInteractions.tap(element.shadowRoot
         .querySelector('.addReviewer'));
   });
@@ -302,6 +300,30 @@
         .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++) {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 6a32834..2f4adf5 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
@@ -18,8 +18,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-comment-thread/gr-comment-thread';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-thread-list_html';
 import {parseDate} from '../../../utils/date-util';
@@ -31,7 +29,18 @@
   PolymerDeepPropertyChange,
 } from '@polymer/polymer/interfaces';
 import {ChangeInfo} from '../../../types/common';
-import {CommentThread, isDraft, UIRobot} from '../../../utils/comment-util';
+import {
+  CommentThread,
+  isDraft,
+  isUnresolved,
+  isDraftThread,
+  isRobotThread,
+  hasHumanReply,
+} from '../../../utils/comment-util';
+import {pluralize} from '../../../utils/string-util';
+import {fireThreadListModifiedEvent} from '../../../utils/event-util';
+import {assertNever} from '../../../utils/common-util';
+import {CommentTabState} from '../../../types/events';
 
 interface CommentThreadWithInfo {
   thread: CommentThread;
@@ -44,9 +53,7 @@
 }
 
 @customElement('gr-thread-list')
-export class GrThreadList extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrThreadList extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -66,6 +73,9 @@
   @property({type: Array})
   _sortedThreads: CommentThread[] = [];
 
+  @property({type: Boolean})
+  showCommentContext = false;
+
   @property({
     computed:
       '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
@@ -89,9 +99,8 @@
   @property({type: Boolean})
   hideToggleButtons = false;
 
-  _computeShowDraftToggle(loggedIn?: boolean) {
-    return loggedIn ? 'show' : '';
-  }
+  @property({type: Object, observer: '_commentTabStateChange'})
+  commentTabState?: CommentTabState;
 
   _showEmptyThreadsMessage(
     threads: CommentThread[],
@@ -113,13 +122,14 @@
   _computeResolvedCommentsMessage(
     threads: CommentThread[],
     displayedThreads: CommentThread[],
-    unresolvedOnly: boolean
+    unresolvedOnly: boolean,
+    onlyShowRobotCommentsWithHumanReply: boolean
   ) {
+    if (onlyShowRobotCommentsWithHumanReply) {
+      threads = this.filterRobotThreadsWithoutHumanReply(threads) ?? [];
+    }
     if (unresolvedOnly && threads.length && !displayedThreads.length) {
-      return (
-        `Show ${threads.length} resolved comment` +
-        (threads.length > 1 ? 's' : '')
-      );
+      return `Show ${pluralize(threads.length, 'resolved comment')}`;
     }
     return '';
   }
@@ -161,7 +171,9 @@
     if (c1.thread.patchNum !== c2.thread.patchNum) {
       if (!c1.thread.patchNum) return 1;
       if (!c2.thread.patchNum) return -1;
-      // TODO(TS): Explicit comparison for 'edit' and 'PARENT' missing?
+      // Threads left on Base when comparing Base vs X have patchNum = X
+      // and CommentSide = PARENT
+      // Threads left on 'edit' have patchNum set as latestPatchNum
       return c1.thread.patchNum > c2.thread.patchNum ? -1 : 1;
     }
 
@@ -379,15 +391,9 @@
     const lastComment = comments.length
       ? comments[comments.length - 1]
       : undefined;
-    let hasRobotComment = false;
-    let hasHumanReplyToRobotComment = false;
-    comments.forEach(comment => {
-      if ((comment as UIRobot).robot_id) {
-        hasRobotComment = true;
-      } else if (hasRobotComment) {
-        hasHumanReplyToRobotComment = true;
-      }
-    });
+    const hasRobotComment = isRobotThread(thread);
+    const hasHumanReplyToRobotComment =
+      hasRobotComment && hasHumanReply(thread);
     let updated = undefined;
     if (lastComment) {
       if (isDraft(lastComment)) updated = lastComment.__date;
@@ -399,7 +405,7 @@
       hasRobotComment,
       hasHumanReplyToRobotComment,
       unresolved: !!lastComment && !!lastComment.unresolved,
-      isEditing: !!lastComment && !!lastComment.__editing,
+      isEditing: isDraft(lastComment) && !!lastComment.__editing,
       hasDraft: !!lastComment && isDraft(lastComment),
       updated,
     };
@@ -421,11 +427,7 @@
   }
 
   _handleCommentsChanged(e: CustomEvent) {
-    this.dispatchEvent(
-      new CustomEvent('thread-list-modified', {
-        detail: {rootId: e.detail.rootId, path: e.detail.path},
-      })
-    );
+    fireThreadListModifiedEvent(this, e.detail.rootId, e.detail.path);
   }
 
   _isOnParent(side?: CommentSide) {
@@ -434,11 +436,75 @@
     return !!side;
   }
 
-  /**
-   * Work around a issue on iOS when clicking turns into double tap
-   */
-  _onTapUnresolvedToggle(e: Event) {
-    e.preventDefault();
+  _handleOnlyUnresolved() {
+    this.unresolvedOnly = true;
+    this._draftsOnly = false;
+  }
+
+  _handleOnlyDrafts() {
+    this._draftsOnly = true;
+    this.unresolvedOnly = false;
+  }
+
+  _handleAllComments() {
+    this._draftsOnly = false;
+    this.unresolvedOnly = false;
+  }
+
+  _showAllComments(draftsOnly?: boolean, unresolvedOnly?: boolean) {
+    return !draftsOnly && !unresolvedOnly;
+  }
+
+  _countUnresolved(threads?: CommentThread[]) {
+    return (
+      this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isUnresolved)
+        .length ?? 0
+    );
+  }
+
+  _countAllThreads(threads?: CommentThread[]) {
+    return this.filterRobotThreadsWithoutHumanReply(threads)?.length ?? 0;
+  }
+
+  _countDrafts(threads?: CommentThread[]) {
+    return (
+      this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isDraftThread)
+        .length ?? 0
+    );
+  }
+
+  filterRobotThreadsWithoutHumanReply(threads?: CommentThread[]) {
+    return threads?.filter(t => !isRobotThread(t) || hasHumanReply(t));
+  }
+
+  _commentTabStateChange(
+    newValue?: CommentTabState,
+    oldValue?: CommentTabState
+  ) {
+    if (!newValue || newValue === oldValue) return;
+    let focusTo: string | undefined;
+    switch (newValue) {
+      case CommentTabState.UNRESOLVED:
+        this._handleOnlyUnresolved();
+        // input is null because it's not rendered yet.
+        focusTo = '#unresolvedRadio';
+        break;
+      case CommentTabState.DRAFTS:
+        this._handleOnlyDrafts();
+        focusTo = '#draftsRadio';
+        break;
+      case CommentTabState.SHOW_ALL:
+        this._handleAllComments();
+        focusTo = '#allRadio';
+        break;
+      default:
+        assertNever(newValue, 'Unsupported preferred state');
+    }
+    const selector = focusTo;
+    window.setTimeout(() => {
+      const input = this.shadowRoot?.querySelector<HTMLInputElement>(selector);
+      input?.focus();
+    }, 0);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
index e55f98a..794e9c9 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -25,29 +25,16 @@
     gr-comment-thread {
       display: block;
       margin-bottom: var(--spacing-m);
-      max-width: 80ch;
     }
     .header {
       align-items: center;
-      background-color: var(--table-header-background-color);
+      background-color: var(--background-color-primary);
       border-bottom: 1px solid var(--border-color);
       border-top: 1px solid var(--border-color);
       display: flex;
       justify-content: left;
-      min-height: 3.2em;
       padding: var(--spacing-m) var(--spacing-l);
     }
-    .toggleItem.draftToggle {
-      display: none;
-    }
-    .toggleItem.draftToggle.show {
-      display: flex;
-    }
-    .toggleItem {
-      align-items: center;
-      display: flex;
-      margin-right: var(--spacing-l);
-    }
     .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
     .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
     .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
@@ -65,27 +52,53 @@
       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);
+    }
   </style>
   <template is="dom-if" if="[[!hideToggleButtons]]">
     <div class="header">
-      <div class="toggleItem">
-        <paper-toggle-button
-          id="unresolvedToggle"
-          checked="{{!unresolvedOnly}}"
-          on-tap="_onTapUnresolvedToggle"
-          >All comments</paper-toggle-button
-        >
-      </div>
-      <div
-        class$="toggleItem draftToggle [[_computeShowDraftToggle(loggedIn)]]"
-      >
-        <paper-toggle-button
-          id="draftToggle"
-          checked="{{_draftsOnly}}"
-          on-tap="_onTapUnresolvedToggle"
-          >Comments with drafts</paper-toggle-button
-        >
-      </div>
+        <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>
     </div>
   </template>
   <div id="threads">
@@ -96,7 +109,7 @@
       <div>
         <span>
           <template is="dom-if" if="[[_showPartyPopper(threads)]]">
-            <span> \&#x1F389 </span>
+            <span class="partypopper">\&#x1F389</span>
           </template>
           [[_computeEmptyThreadsMessage(threads, _displayedThreads,
           unresolvedOnly)]]
@@ -106,7 +119,7 @@
               link
               on-click="_handleResolvedCommentsMessageClick">
                 [[_computeResolvedCommentsMessage(threads, _displayedThreads,
-                unresolvedOnly)]]
+                unresolvedOnly, onlyShowRobotCommentsWithHumanReply)]]
             </gr-button>
           </template>
         </span>
@@ -127,9 +140,11 @@
       </template>
       <gr-comment-thread
         show-file-path=""
+        show-ported-comment="[[thread.ported]]"
+        show-comment-context="[[showCommentContext]]"
         change-num="[[changeNum]]"
         comments="[[thread.comments]]"
-        comment-side="[[thread.commentSide]]"
+        diff-side="[[thread.diffSide]]"
         show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
         project-name="[[change.project]]"
         is-on-parent="[[_isOnParent(thread.commentSide)]]"
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
index efc072f..7b47bd7 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
@@ -43,7 +43,7 @@
       {
         comments: [
           {
-            __path: '/COMMIT_MSG',
+            path: '/COMMIT_MSG',
             author: {
               _account_id: 1000000,
               name: 'user',
@@ -79,7 +79,7 @@
       {
         comments: [
           {
-            __path: 'test.txt',
+            path: 'test.txt',
             author: {
               _account_id: 1000000,
               name: 'user',
@@ -102,7 +102,7 @@
       {
         comments: [
           {
-            __path: '/COMMIT_MSG',
+            path: '/COMMIT_MSG',
             author: {
               _account_id: 1000000,
               name: 'user',
@@ -123,7 +123,7 @@
       {
         comments: [
           {
-            __path: '/COMMIT_MSG',
+            path: '/COMMIT_MSG',
             author: {
               _account_id: 1000000,
               name: 'user',
@@ -201,7 +201,7 @@
       {
         comments: [
           {
-            __path: '/COMMIT_MSG',
+            path: '/COMMIT_MSG',
             author: {
               _account_id: 1000000,
               name: 'user',
@@ -225,7 +225,7 @@
       {
         comments: [
           {
-            __path: '/COMMIT_MSG',
+            path: '/COMMIT_MSG',
             author: {
               _account_id: 1000000,
               name: 'user',
@@ -240,7 +240,7 @@
             robot_id: 'rc2',
           },
           {
-            __path: '/COMMIT_MSG',
+            path: '/COMMIT_MSG',
             author: {
               _account_id: 1000000,
               name: 'user',
@@ -270,16 +270,6 @@
     });
   });
 
-  test('draft toggle only appears when logged in', () => {
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.draftToggle')).display,
-    'none');
-    element.loggedIn = true;
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('.draftToggle')).display,
-    'none');
-  });
-
   test('show all threads by default', () => {
     assert.equal(dom(element.root)
         .querySelectorAll('gr-comment-thread').length, element.threads.length);
@@ -548,44 +538,26 @@
     });
   });
 
-  test('toggle unresolved shows all comments', () => {
+  test('toggle all shows all all comments', () => {
     MockInteractions.tap(element.shadowRoot.querySelector(
-        '#unresolvedToggle'));
+        '#allRadio'));
+    flush();
+    assert.equal(getVisibleThreads().length, 9);
+  });
+
+  test('toggle unresolved shows all unresolved comments', () => {
+    MockInteractions.tap(element.shadowRoot.querySelector(
+        '#unresolvedRadio'));
     flush();
     assert.equal(getVisibleThreads().length, 4);
   });
 
   test('toggle drafts only shows threads with draft comments', () => {
-    MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
+    MockInteractions.tap(element.shadowRoot.querySelector('#draftsRadio'));
     flush();
     assert.equal(getVisibleThreads().length, 2);
   });
 
-  test('toggle drafts and unresolved should ignore comments in editing', () => {
-    const modifiedThreads = [...element.threads];
-    modifiedThreads[5] = {...modifiedThreads[5]};
-    modifiedThreads[5].comments = [...modifiedThreads[5].comments];
-    modifiedThreads[5].comments.push({
-      ...modifiedThreads[5].comments[0],
-      __editing: true,
-    });
-    element.threads = modifiedThreads;
-    MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
-    MockInteractions.tap(element.shadowRoot.querySelector(
-        '#unresolvedToggle'));
-    flush();
-    assert.equal(getVisibleThreads().length, 2);
-  });
-
-  test('toggle drafts and unresolved only shows threads with drafts and ' +
-      'publicly unresolved ', () => {
-    MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
-    MockInteractions.tap(element.shadowRoot.querySelector(
-        '#unresolvedToggle'));
-    flush();
-    assert.equal(getVisibleThreads().length, 1);
-  });
-
   test('modification events are consumed and displatched', () => {
     sinon.spy(element, '_handleCommentsChanged');
     const dispatchSpy = sinon.stub();
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts
deleted file mode 100644
index cab17dd..0000000
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts
+++ /dev/null
@@ -1,149 +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 '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import '../../shared/gr-shell-command/gr-shell-command';
-import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-upload-help-dialog_html';
-import {customElement, property} from '@polymer/decorators';
-import {RevisionInfo} from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-
-const COMMIT_COMMAND = 'git add . && git commit --amend --no-edit';
-const PUSH_COMMAND_PREFIX = 'git push origin HEAD:refs/for/';
-
-// Command names correspond to download plugin definitions.
-const PREFERRED_FETCH_COMMAND_ORDER = ['checkout', 'cherry pick', 'pull'];
-
-export interface GrUploadHelpDialog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
-@customElement('gr-upload-help-dialog')
-export class GrUploadHelpDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when the user presses the close button.
-   *
-   * @event close
-   */
-
-  @property({type: Object})
-  revision?: RevisionInfo;
-
-  @property({type: String})
-  targetBranch?: string;
-
-  @property({type: String})
-  _commitCommand = COMMIT_COMMAND;
-
-  @property({
-    type: String,
-    computed: '_computeFetchCommand(revision, _preferredDownloadScheme)',
-  })
-  _fetchCommand?: string;
-
-  @property({type: String})
-  _preferredDownloadScheme?: string;
-
-  @property({type: String, computed: '_computePushCommand(targetBranch)'})
-  _pushCommand?: string;
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.$.restAPI
-      .getLoggedIn()
-      .then(loggedIn =>
-        loggedIn ? this.$.restAPI.getPreferences() : Promise.resolve(undefined)
-      )
-      .then(prefs => {
-        if (prefs) {
-          // TODO(TS): The download_command pref was deleted in change 249223.
-          // this._preferredDownloadCommand = prefs.download_command;
-          this._preferredDownloadScheme = prefs.download_scheme;
-        }
-      });
-  }
-
-  _handleCloseTap(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('close', {
-        composed: true,
-        bubbles: false,
-      })
-    );
-  }
-
-  _computeFetchCommand(
-    revision?: RevisionInfo,
-    scheme?: string
-  ): string | undefined {
-    if (!revision || !revision.fetch) return undefined;
-    if (!scheme) {
-      const keys = Object.keys(revision.fetch).sort();
-      if (keys.length === 0) {
-        return undefined;
-      }
-      scheme = keys[0];
-    }
-    if (
-      !scheme ||
-      !revision.fetch[scheme] ||
-      !revision.fetch[scheme].commands
-    ) {
-      return undefined;
-    }
-
-    const cmds: {[key: string]: string} = {};
-    Object.entries(revision.fetch[scheme].commands!).forEach(([key, cmd]) => {
-      cmds[key.toLowerCase()] = cmd;
-    });
-
-    // If no supported command preference is given, look for known commands
-    // from the downloads plugin in order of preference.
-    for (let i = 0; i < PREFERRED_FETCH_COMMAND_ORDER.length; i++) {
-      if (cmds[PREFERRED_FETCH_COMMAND_ORDER[i]]) {
-        return cmds[PREFERRED_FETCH_COMMAND_ORDER[i]];
-      }
-    }
-
-    return undefined;
-  }
-
-  _computePushCommand(targetBranch: string) {
-    return PUSH_COMMAND_PREFIX + targetBranch;
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-upload-help-dialog': GrUploadHelpDialog;
-  }
-}
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.ts
deleted file mode 100644
index 1ee3a3a..0000000
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_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 {
-      background-color: var(--dialog-background-color);
-      display: block;
-    }
-    .main {
-      width: 100%;
-    }
-    ol {
-      margin-left: var(--spacing-xl);
-      list-style: decimal;
-    }
-    p {
-      margin-bottom: var(--spacing-m);
-    }
-  </style>
-  <gr-dialog confirm-label="Done" cancel-label="" on-confirm="_handleCloseTap">
-    <div class="header" slot="header">How to update this change:</div>
-    <div class="main" slot="main">
-      <ol>
-        <li>
-          <p>
-            Checkout this change locally and make your desired modifications to
-            the files.
-          </p>
-          <template is="dom-if" if="[[_fetchCommand]]">
-            <gr-shell-command command="[[_fetchCommand]]"></gr-shell-command>
-          </template>
-        </li>
-        <li>
-          <p>
-            Update the local commit with your modifications using the following
-            command.
-          </p>
-          <gr-shell-command command="[[_commitCommand]]"></gr-shell-command>
-          <p>
-            Leave the "Change-Id:" line of the commit message as is.
-          </p>
-        </li>
-        <li>
-          <p>Push the updated commit to Gerrit.</p>
-          <gr-shell-command command="[[_pushCommand]]"></gr-shell-command>
-        </li>
-        <li>
-          <p>Refresh this page to view the the update.</p>
-        </li>
-      </ol>
-    </div>
-  </gr-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.js
deleted file mode 100644
index 005b20d..0000000
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.js
+++ /dev/null
@@ -1,97 +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-upload-help-dialog.js';
-
-const basicFixture = fixtureFromElement('gr-upload-help-dialog');
-
-suite('gr-upload-help-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('constructs push command from branch', () => {
-    element.targetBranch = 'foo';
-    assert.equal(element._pushCommand, 'git push origin HEAD:refs/for/foo');
-
-    element.targetBranch = 'master';
-    assert.equal(element._pushCommand,
-        'git push origin HEAD:refs/for/master');
-  });
-
-  suite('fetch command', () => {
-    const testRev = {
-      fetch: {
-        http: {
-          commands: {
-            Checkout: 'http checkout',
-            Pull: 'http pull',
-          },
-        },
-        ssh: {
-          commands: {
-            Pull: 'ssh pull',
-          },
-        },
-      },
-    };
-
-    test('null cases', () => {
-      assert.isUndefined(element._computeFetchCommand());
-      assert.isUndefined(element._computeFetchCommand({}));
-      assert.isUndefined(element._computeFetchCommand({fetch: null}));
-      assert.isUndefined(element._computeFetchCommand({fetch: {}}));
-    });
-
-    test('revision not defined', () => {
-      assert.isUndefined(
-          element._computeFetchCommand(undefined, ''));
-    });
-
-    test('insufficiently defined scheme', () => {
-      assert.isUndefined(
-          element._computeFetchCommand(testRev, 'badscheme'));
-
-      const rev = {...testRev};
-      rev.fetch = {...testRev.fetch, nocmds: {commands: {}}};
-      assert.isUndefined(
-          element._computeFetchCommand(rev, 'nocmds'));
-
-      rev.fetch.nocmds.commands.unsupported = 'unsupported';
-      assert.isUndefined(
-          element._computeFetchCommand(rev, 'nocmds'));
-    });
-
-    test('default scheme and command', () => {
-      const cmd = element._computeFetchCommand(testRev, '');
-      assert.isTrue(cmd === 'http checkout' || cmd === 'ssh pull');
-    });
-
-    test('default command', () => {
-      assert.strictEqual(
-          element._computeFetchCommand(testRev, 'http'),
-          'http checkout');
-      assert.strictEqual(
-          element._computeFetchCommand(testRev, 'ssh'),
-          'ssh pull');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
new file mode 100644
index 0000000..57bed1c
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -0,0 +1,1108 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the 'License');
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an 'AS IS' BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from 'lit-html';
+import {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 '@polymer/paper-tooltip/paper-tooltip';
+import {
+  Action,
+  Category,
+  Link,
+  LinkIcon,
+  RunStatus,
+  Tag,
+} from '../../api/checks';
+import {sharedStyles} from '../../styles/shared-styles';
+import {
+  allActions$,
+  checksPatchsetNumber$,
+  someProvidersAreLoading$,
+  RunResult,
+  CheckRun,
+  allLinks$,
+} from '../../services/checks/checks-model';
+import {
+  allResults,
+  fireActionTriggered,
+  hasCompletedWithoutResults,
+  hasResultsOf,
+  iconForCategory,
+  iconForLink,
+  tooltipForLink,
+} from '../../services/checks/checks-util';
+import {
+  assertIsDefined,
+  check,
+  checkRequiredProperty,
+} from '../../utils/common-util';
+import {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 {ChecksTabState} from '../../types/events';
+import {ConfigInfo, PatchSetNumber} from '../../types/common';
+import {latestPatchNum$} from '../../services/change/change-model';
+import {appContext} from '../../services/app-context';
+import {repoConfig$} from '../../services/config/config-model';
+
+@customElement('gr-result-row')
+class GrResultRow extends GrLitElement {
+  @property()
+  result?: RunResult;
+
+  @property()
+  isExpanded = false;
+
+  @property({type: Boolean, reflect: true})
+  isExpandable = false;
+
+  @property()
+  shouldRender = false;
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: contents;
+        }
+        :host([isexpandable]) {
+          cursor: pointer;
+        }
+        gr-result-expanded {
+          cursor: default;
+        }
+        tr {
+          border-top: 1px solid var(--border-color);
+        }
+        iron-icon.link {
+          color: var(--link-color);
+          margin-right: var(--spacing-m);
+        }
+        td.iconCol {
+          padding-left: var(--spacing-l);
+          padding-right: var(--spacing-m);
+        }
+        .iconCol div {
+          width: 20px;
+        }
+        .nameCol div {
+          width: 165px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+        .nameCol .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);
+        }
+        .summaryCol {
+          /* Forces this column to get the remaining space that is left over by
+             the other columns. */
+          width: 99%;
+        }
+        .expanderCol div {
+          width: 20px;
+        }
+        td {
+          white-space: nowrap;
+          padding: var(--spacing-s);
+        }
+        td .summary-cell {
+          display: flex;
+          max-width: calc(100vw - 630px);
+        }
+        td .summary-cell .summary {
+          font-weight: var(--font-weight-bold);
+          flex-shrink: 1;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          margin-right: var(--spacing-s);
+        }
+        td .summary-cell .message {
+          flex-grow: 1;
+          /* Looks a bit stupid, but the idea is that .message shrinks first,
+             and only when that has shrunken to 0, then .summary should also
+             start shrinking (substantially). */
+          flex-shrink: 1000000;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+        tr: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,
+        :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 {
+          display: none;
+        }
+        tr.collapsed:hover .summary-cell .tags,
+        tr.collapsed:hover .summary-cell .label {
+          display: none;
+        }
+        td .summary-cell .tags .tag {
+          color: var(--primary-text-color);
+          display: inline-block;
+          border-radius: 20px;
+          background-color: var(--tag-background);
+          padding: 0 var(--spacing-m);
+          margin-left: var(--spacing-s);
+        }
+        td .summary-cell .label {
+          color: var(--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 {
+          background-color: var(--tag-gray);
+        }
+        .tag.yellow {
+          background-color: var(--tag-yellow);
+        }
+        .tag.pink {
+          background-color: var(--tag-pink);
+        }
+        .tag.purple {
+          background-color: var(--tag-purple);
+        }
+        .tag.cyan {
+          background-color: var(--tag-cyan);
+        }
+        .tag.brown {
+          background-color: var(--tag-brown);
+        }
+        .actions gr-checks-action,
+        .actions gr-dropdown {
+          /* Fitting a 28px button into 20px line-height. */
+          margin: -4px 0;
+          vertical-align: top;
+        }
+        #moreActions iron-icon {
+          color: var(--link-color);
+        }
+        #moreMessage {
+          display: none;
+        }
+      `,
+    ];
+  }
+
+  update(changedProperties: PropertyValues) {
+    if (changedProperties.has('result')) {
+      this.isExpandable = !!this.result?.summary && !!this.result?.message;
+    }
+    super.update(changedProperties);
+  }
+
+  firstUpdated() {
+    const loading = this.shadowRoot?.querySelector('.container');
+    assertIsDefined(loading, '"Loading" element');
+    whenVisible(loading, () => this.setAttribute('shouldRender', 'true'), 200);
+  }
+
+  render() {
+    if (!this.result) return '';
+    if (!this.shouldRender) {
+      return html`
+        <tr class="container">
+          <td class="iconCol"></td>
+          <td class="nameCol">
+            <div><span class="loading">Loading...</span></div>
+          </td>
+          <td class="summaryCol"></td>
+          <td class="expanderCol"></td>
+        </tr>
+      `;
+    }
+    return html`
+      <tr class="${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
+            >
+          </div>
+        </td>
+        <td class="summaryCol">
+          <div class="summary-cell">
+            ${(this.result.links?.slice(0, 1) ?? []).map(this.renderLink)}
+            ${this.renderSummary(this.result.summary)}
+            <div class="message" @click="${this.toggleExpanded}">
+              ${this.isExpanded ? '' : this.result.message}
+            </div>
+            <div class="tags">
+              ${(this.result.tags ?? []).map(t => this.renderTag(t))}
+            </div>
+            ${this.renderLabel()} ${this.renderLinks()} ${this.renderActions()}
+          </div>
+          ${this.renderExpanded()}
+        </td>
+        <td class="expanderCol" @click="${this.toggleExpanded}">
+          <div
+            class="show-hide"
+            role="switch"
+            tabindex="0"
+            ?hidden="${!this.isExpandable}"
+            ?aria-checked="${this.isExpanded}"
+            aria-label="${this.isExpanded
+              ? 'Collapse result row'
+              : 'Expand result row'}"
+            @keydown="${this.toggleExpanded}"
+          >
+            <iron-icon
+              icon="${this.isExpanded
+                ? 'gr-icons:expand-less'
+                : 'gr-icons:expand-more'}"
+            ></iron-icon>
+          </div>
+        </td>
+      </tr>
+    `;
+  }
+
+  private renderExpanded() {
+    if (!this.isExpanded) return;
+    return html`<gr-result-expanded
+      .result="${this.result}"
+    ></gr-result-expanded>`;
+  }
+
+  private toggleExpanded() {
+    if (!this.isExpandable) return;
+    this.isExpanded = !this.isExpanded;
+  }
+
+  renderSummary(text?: string) {
+    if (!text) return;
+    return html`
+      <!-- The &nbsp; is for being able to shrink a tiny amount without
+       the text itself getting shrunk with an ellipsis. -->
+      <div class="summary" @click="${this.toggleExpanded}">${text}&nbsp;</div>
+    `;
+  }
+
+  renderIcon() {
+    if (this.result?.status !== RunStatus.RUNNING) return;
+    return html`<iron-icon icon="gr-icons:timelapse"></iron-icon>`;
+  }
+
+  renderLabel() {
+    const label = this.result?.labelName;
+    if (!label) return;
+    return html`<div class="label">${label}</div>`;
+  }
+
+  renderLinks() {
+    const links = (this.result?.links ?? []).slice(1);
+    if (links.length === 0) return;
+    return html`<div class="links">${links.map(this.renderLink)}</div>`;
+  }
+
+  renderLink(link: Link) {
+    const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
+    return html`<a href="${link.url}" target="_blank"
+      ><iron-icon
+        aria-label="external link to details"
+        class="link"
+        icon="gr-icons:${iconForLink(link.icon)}"
+      ></iron-icon
+      ><paper-tooltip offset="5">${tooltipText}</paper-tooltip></a
+    >`;
+  }
+
+  private renderActions() {
+    const actions = this.result?.actions ?? [];
+    if (actions.length === 0) return;
+    const overflowItems = actions.slice(2).map(action => {
+      return {...action, id: action.name};
+    });
+    return html`<div class="actions">
+      ${this.renderAction(actions[0])} ${this.renderAction(actions[1])}
+      <gr-dropdown
+        id="moreActions"
+        link=""
+        vertical-offset="32"
+        horizontal-align="right"
+        @tap-item="${this.handleAction}"
+        @opened-changed="${(e: CustomEvent) =>
+          toggleClass(this, 'dropdown-open', e.detail.value)}"
+        ?hidden="${overflowItems.length === 0}"
+        .items="${overflowItems}"
+      >
+        <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
+        </iron-icon>
+        <span id="moreMessage">More</span>
+      </gr-dropdown>
+    </div>`;
+  }
+
+  private handleAction(e: CustomEvent<Action>) {
+    fireActionTriggered(this, e.detail);
+  }
+
+  private renderAction(action?: Action) {
+    if (!action) return;
+    return html`<gr-checks-action .action="${action}"></gr-checks-action>`;
+  }
+
+  renderPrimaryActions() {
+    const primaryActions = (this.result?.actions ?? []).slice(0, 2);
+    if (primaryActions.length === 0) return;
+    return html`
+      <div class="primaryActions">${primaryActions.map(this.renderAction)}</div>
+    `;
+  }
+
+  renderSecondaryActions() {
+    const secondaryActions = (this.result?.actions ?? []).slice(2);
+    if (secondaryActions.length === 0) return;
+    return html`
+      <div class="secondaryActions">
+        ${secondaryActions.map(this.renderAction)}
+      </div>
+    `;
+  }
+
+  renderTag(tag: Tag) {
+    return html`<div class="tag ${tag.color}">${tag.name}</div>`;
+  }
+}
+
+@customElement('gr-result-expanded')
+class GrResultExpanded extends GrLitElement {
+  @property()
+  result?: RunResult;
+
+  @property()
+  repoConfig?: ConfigInfo;
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        .message {
+          padding: var(--spacing-m) var(--spacing-m) var(--spacing-m) 0;
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    this.subscribe('repoConfig', repoConfig$);
+  }
+
+  render() {
+    if (!this.result) return '';
+    return html`
+      <gr-endpoint-decorator name="check-result-expanded">
+        <gr-endpoint-param
+          name="run"
+          .value="${this.result}"
+        ></gr-endpoint-param>
+        <gr-endpoint-param
+          name="result"
+          .value="${this.result}"
+        ></gr-endpoint-param>
+        <gr-formatted-text
+          no-trailing-margin=""
+          class="message"
+          content="${this.result.message}"
+          config="${this.repoConfig}"
+        ></gr-formatted-text>
+      </gr-endpoint-decorator>
+    `;
+  }
+}
+
+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);
+
+@customElement('gr-checks-results')
+export class GrChecksResults extends GrLitElement {
+  @query('#filterInput')
+  filterInput?: HTMLInputElement;
+
+  @internalProperty()
+  filterRegExp = new RegExp('');
+
+  /** All runs. Shown should only the selected/filtered ones. */
+  @property()
+  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()
+  selectedRuns: string[] = [];
+
+  @property()
+  actions: Action[] = [];
+
+  @property()
+  links: Link[] = [];
+
+  @property()
+  tabState?: ChecksTabState;
+
+  @property()
+  someProvidersAreLoading = false;
+
+  @property()
+  checksPatchsetNumber: PatchSetNumber | undefined = undefined;
+
+  @property()
+  latestPatchsetNumber: PatchSetNumber | undefined = undefined;
+
+  /** Maps checkName to selected attempt number. `undefined` means `latest`. */
+  @property()
+  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();
+
+  /**
+   * 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>();
+
+  /**
+   * 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 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$);
+  }
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          background-color: var(--background-color-secondary);
+        }
+        .header {
+          display: block;
+          background-color: var(--background-color-primary);
+          padding: var(--spacing-l) var(--spacing-xl) var(--spacing-m)
+            var(--spacing-xl);
+          border-bottom: 1px solid var(--border-color);
+        }
+        .headerTopRow,
+        .headerBottomRow {
+          display: flex;
+          justify-content: space-between;
+          align-items: flex-end;
+        }
+        .headerTopRow gr-dropdown-list {
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          padding: 0 var(--spacing-m);
+        }
+        .headerBottomRow {
+          margin-top: var(--spacing-s);
+        }
+        .headerBottomRow .right {
+          display: flex;
+          align-items: center;
+        }
+        .headerBottomRow .links iron-icon {
+          color: var(--link-color);
+          margin-right: var(--spacing-l);
+        }
+        #moreActions iron-icon {
+          color: var(--link-color);
+        }
+        #moreMessage {
+          display: none;
+        }
+        .body {
+          display: block;
+          padding: var(--spacing-s) var(--spacing-xl) var(--spacing-xl)
+            var(--spacing-xl);
+        }
+        .filterDiv {
+          display: flex;
+          margin-top: var(--spacing-s);
+          align-items: center;
+        }
+        .filterDiv input#filterInput {
+          padding: var(--spacing-s) var(--spacing-m);
+          min-width: 400px;
+        }
+        .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);
+          cursor: default;
+        }
+        .categoryHeader .title {
+          text-transform: capitalize;
+        }
+        .categoryHeader .expandIcon {
+          width: var(--line-height-h3);
+          height: var(--line-height-h3);
+          margin-right: var(--spacing-s);
+        }
+        .categoryHeader .statusIcon {
+          position: relative;
+          top: 2px;
+        }
+        .categoryHeader .statusIcon.error {
+          color: var(--error-foreground);
+        }
+        .categoryHeader .statusIcon.warning {
+          color: var(--warning-foreground);
+        }
+        .categoryHeader .statusIcon.info {
+          color: var(--info-foreground);
+        }
+        .categoryHeader .statusIcon.success {
+          color: var(--success-foreground);
+        }
+        .categoryHeader .filtered {
+          color: var(--deemphasized-text-color);
+        }
+        .collapsed .noResultsMessage,
+        .collapsed table {
+          display: none;
+        }
+        .collapsed {
+          border-bottom: 1px solid var(--border-color);
+          padding-bottom: var(--spacing-m);
+        }
+        .noResultsMessage {
+          width: 100%;
+          max-width: 1280px;
+          margin-top: var(--spacing-m);
+          background-color: var(--background-color-primary);
+          box-shadow: var(--elevation-level-1);
+          padding: var(--spacing-s)
+            calc(20px + var(--spacing-l) + var(--spacing-m) + var(--spacing-s));
+        }
+        table.resultsTable {
+          width: 100%;
+          max-width: 1280px;
+          margin-top: var(--spacing-m);
+          background-color: var(--background-color-primary);
+          box-shadow: var(--elevation-level-1);
+        }
+        tr.headerRow th {
+          text-align: left;
+          font-weight: var(--font-weight-bold);
+          padding: var(--spacing-s);
+        }
+        gr-button.showAll {
+          margin: var(--spacing-m);
+        }
+        tr {
+          border-top: 1px solid var(--border-color);
+        }
+      `,
+    ];
+  }
+
+  protected updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
+    if (changedProperties.has('tabState') && this.tabState) {
+      const {statusOrCategory, checkName} = this.tabState;
+      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)}`);
+      }
+    }
+  }
+
+  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'});
+    });
+  }
+
+  render() {
+    return html`
+      <div class="header">
+        <div class="headerTopRow">
+          <div class="left">
+            <h2 class="heading-2">Results</h2>
+          </div>
+          <div class="middle">
+            <span ?hidden="${!this.someProvidersAreLoading}">Loading...</span>
+          </div>
+          <div class="right">
+            <gr-dropdown-list
+              value="${this.checksPatchsetNumber}"
+              .items="${this.createPatchsetDropdownItems()}"
+              @value-change="${this.onPatchsetSelected}"
+            ></gr-dropdown-list>
+          </div>
+        </div>
+        <div class="headerBottomRow">
+          <div class="left">${this.renderFilter()}</div>
+          <div class="right">${this.renderLinks()}${this.renderActions()}</div>
+        </div>
+      </div>
+      <div class="body">
+        ${this.renderSection(Category.ERROR)}
+        ${this.renderSection(Category.WARNING)}
+        ${this.renderSection(Category.INFO)} ${this.renderSection('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 renderLink(link: Link) {
+    const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
+    return html`<a href="${link.url}" target="_blank"
+      ><iron-icon
+        aria-label="${tooltipText}"
+        class="link"
+        icon="gr-icons:${iconForLink(link.icon)}"
+      ></iron-icon
+      ><paper-tooltip offset="5">${tooltipText}</paper-tooltip></a
+    >`;
+  }
+
+  private renderActions() {
+    const overflowItems = this.actions.slice(2).map(action => {
+      return {...action, id: action.name};
+    });
+    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}"
+      >
+        <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
+        </iron-icon>
+        <span id="moreMessage">More</span>
+      </gr-dropdown>
+    `;
+  }
+
+  private handleAction(e: CustomEvent<Action>) {
+    fireActionTriggered(this, e.detail);
+  }
+
+  private renderAction(action?: Action) {
+    if (!action) return;
+    return html`<gr-checks-action .action="${action}"></gr-checks-action>`;
+  }
+
+  private onPatchsetSelected(e: CustomEvent<{value: string}>) {
+    const patchset = Number(e.detail.value);
+    check(!isNaN(patchset), 'selected patchset must be a number');
+    this.checksService.setPatchset(patchset as PatchSetNumber);
+  }
+
+  private createPatchsetDropdownItems() {
+    if (!this.latestPatchsetNumber) return [];
+    return Array.from(Array(this.latestPatchsetNumber), (_, i) => {
+      assertIsDefined(this.latestPatchsetNumber, 'latestPatchsetNumber');
+      const index = this.latestPatchsetNumber - i;
+      const postfix = index === this.latestPatchsetNumber ? ' (latest)' : '';
+      return {
+        value: `${index}`,
+        text: `Patchset ${index}${postfix}`,
+      };
+    });
+  }
+
+  isRunSelected(run: {checkName: string}) {
+    return (
+      this.selectedRuns.length === 0 ||
+      this.selectedRuns.includes(run.checkName)
+    );
+  }
+
+  renderFilter() {
+    const runs = this.runs.filter(
+      run =>
+        this.isRunSelected(run) && isAttemptSelected(this.selectedAttempts, run)
+    );
+    if (this.selectedRuns.length === 0 && allResults(runs).length <= 3) {
+      if (this.filterRegExp.source.length > 0) {
+        this.filterRegExp = new RegExp('');
+      }
+      return;
+    }
+    return html`
+      <div class="filterDiv">
+        <input
+          id="filterInput"
+          type="text"
+          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') {
+    const catString = category.toString().toLowerCase();
+    let 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,
+        ...this.computeRunResults(category, run),
+      ],
+      []
+    );
+    const selected = all.filter(result => this.isRunSelected(result));
+    const filtered = selected.filter(
+      result =>
+        this.filterRegExp.test(result.checkName) ||
+        this.filterRegExp.test(result.summary)
+    );
+    let expanded = this.isSectionExpanded.get(category);
+    const expandedByUser = this.isSectionExpandedByUser.get(category) ?? false;
+    if (!expandedByUser || expanded === undefined) {
+      expanded = selected.length > 0;
+      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 showAllButton = this.renderShowAllButton(
+      category,
+      isShowAll,
+      showAllThreshold,
+      resultCount
+    );
+    return html`
+      <div class="${expandedClass}">
+        <h3
+          class="categoryHeader ${catString} heading-3"
+          @click="${() => this.toggleExpanded(category)}"
+        >
+          <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
+          <iron-icon
+            icon="gr-icons:${iconForCategory(category)}"
+            class="statusIcon ${catString}"
+          ></iron-icon>
+          <span class="title">${catString}</span>
+          <span class="count"
+            >${this.renderCount(all, selected, filtered)}</span
+          >
+        </h3>
+        ${this.renderResults(
+          all,
+          selected,
+          filtered,
+          resultLimit,
+          showAllButton
+        )}
+      </div>
+    `;
+  }
+
+  renderShowAllButton(
+    category: Category | 'SUCCESS',
+    isShowAll: boolean,
+    showAllThreshold: number,
+    resultCount: number
+  ) {
+    if (resultCount <= showAllThreshold) return;
+    const message = isShowAll ? 'Show Less' : `Show All (${resultCount})`;
+    const handler = () => this.toggleShowAll(category);
+    return html`
+      <tr class="showAllRow">
+        <td colspan="4">
+          <gr-button class="showAll" link @click="${handler}"
+            >${message}</gr-button
+          >
+        </td>
+      </tr>
+    `;
+  }
+
+  toggleShowAll(category: Category | 'SUCCESS') {
+    const current = this.isShowAll.get(category) ?? false;
+    this.isShowAll.set(category, !current);
+    this.requestUpdate();
+  }
+
+  renderResults(
+    all: RunResult[],
+    selected: RunResult[],
+    filtered: RunResult[],
+    limit: number,
+    showAll: TemplateResult | undefined
+  ) {
+    if (all.length === 0) {
+      return html`<div class="noResultsMessage">No results</div>`;
+    }
+    if (selected.length === 0) {
+      return html`<div class="noResultsMessage">
+        No results for this filtered view
+      </div>`;
+    }
+    if (filtered.length === 0) {
+      return html`<div class="noResultsMessage">
+        No results match the regular expression
+      </div>`;
+    }
+    filtered = filtered.slice(0, limit);
+    return html`
+      <table class="resultsTable">
+        <thead>
+          <tr class="headerRow">
+            <th class="iconCol"></th>
+            <th class="nameCol">Run</th>
+            <th class="summaryCol">Summary</th>
+            <th class="expanderCol"></th>
+          </tr>
+        </thead>
+        <tbody>
+          ${repeat(
+            filtered,
+            result => result.internalResultId,
+            result => html`
+              <gr-result-row
+                class="${charsOnly(result.checkName)}"
+                .result="${result}"
+              ></gr-result-row>
+            `
+          )}
+          ${showAll}
+        </tbody>
+      </table>
+    `;
+  }
+
+  renderCount(all: RunResult[], selected: 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') {
+    const expanded = this.isSectionExpanded.get(category);
+    assertIsDefined(expanded, 'expanded must have been set in initial render');
+    this.isSectionExpanded.set(category, !expanded);
+    this.isSectionExpandedByUser.set(category, true);
+    this.requestUpdate();
+  }
+
+  computeRunResults(category: Category | 'SUCCESS', run: CheckRun) {
+    if (category === 'SUCCESS') return [this.computeSuccessfulRunResult(run)];
+    return (
+      run.results
+        ?.filter(result => result.category === category)
+        .map(result => {
+          return {...run, ...result};
+        }) ?? []
+    );
+  }
+
+  computeSuccessfulRunResult(run: CheckRun): RunResult {
+    const adaptedRun: RunResult = {
+      internalResultId: run.internalRunId + '-0',
+      category: Category.INFO, // will not be used, but is required
+      summary: run.statusDescription ?? '',
+      ...run,
+    };
+    if (!run.statusDescription) {
+      const start = run.scheduledTimestamp ?? run.startedTimestamp;
+      const end = run.finishedTimestamp;
+      let duration = '';
+      if (start && end) {
+        duration = ` in ${durationString(start, end, true)}`;
+      }
+      adaptedRun.message = `Completed without results${duration}.`;
+    }
+    if (run.statusLink) {
+      adaptedRun.links = [
+        {
+          url: run.statusLink,
+          primary: true,
+          icon: LinkIcon.EXTERNAL,
+        },
+      ];
+    }
+    return adaptedRun;
+  }
+}
+
+@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/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
similarity index 68%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
index ac59f4f..babfd42 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
@@ -14,8 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+import '../../test/common-test-setup-karma';
+import {GrChecksResults} from './gr-checks-results';
+
+suite('gr-checks-results test', () => {
+  test('is defined', () => {
+    const el = document.createElement('gr-checks-results');
+    assert.instanceOf(el, GrChecksResults);
+  });
+});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
new file mode 100644
index 0000000..de9c5fb
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -0,0 +1,557 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the 'License');
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an 'AS IS' BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html, nothing} from 'lit-html';
+import {classMap} from 'lit-html/directives/class-map';
+import './gr-hovercard-run';
+import {
+  css,
+  customElement,
+  internalProperty,
+  property,
+  PropertyValues,
+  query,
+} from 'lit-element';
+import {GrLitElement} from '../lit/gr-lit-element';
+import {Action, Link, RunStatus} from '../../api/checks';
+import {sharedStyles} from '../../styles/shared-styles';
+import {
+  AttemptDetail,
+  compareByWorstCategory,
+  fireActionTriggered,
+  iconForCategory,
+  iconForRun,
+  primaryRunAction,
+  worstCategory,
+} from '../../services/checks/checks-util';
+import {
+  CheckRun,
+  allRuns$,
+  fakeActions,
+  fakeRun0,
+  fakeRun1,
+  fakeRun2,
+  fakeRun3,
+  fakeRun4_1,
+  fakeRun4_2,
+  fakeRun4_3,
+  fakeRun4_4,
+  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 {ChecksTabState} from '../../types/events';
+import {charsOnly} from '../../utils/string-util';
+
+@customElement('gr-checks-run')
+export class GrChecksRun extends GrLitElement {
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          --thick-border: 6px;
+        }
+        .chip {
+          display: flex;
+          justify-content: space-between;
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          padding: var(--spacing-s) var(--spacing-m);
+          margin-top: var(--spacing-s);
+          cursor: pointer;
+        }
+        .left {
+          overflow: hidden;
+          white-space: nowrap;
+          text-overflow: ellipsis;
+        }
+        .name {
+          font-weight: var(--font-weight-bold);
+        }
+        .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);
+        }
+        .chip.warning {
+          border-left: var(--thick-border) solid var(--warning-foreground);
+        }
+        .chip.info-outline {
+          border-left: var(--thick-border) solid var(--info-foreground);
+        }
+        .chip.check-circle-outline {
+          border-left: var(--thick-border) solid var(--success-foreground);
+        }
+        .chip.timelapse {
+          border-left: var(--thick-border) solid var(--border-color);
+        }
+        .chip.placeholder {
+          border-left: var(--thick-border) solid var(--border-color);
+        }
+        .chip.placeholder iron-icon {
+          display: none;
+        }
+        iron-icon.error {
+          color: var(--error-foreground);
+        }
+        iron-icon.warning {
+          color: var(--warning-foreground);
+        }
+        iron-icon.info-outline {
+          color: var(--info-foreground);
+        }
+        iron-icon.check-circle-outline {
+          color: var(--success-foreground);
+        }
+        /* Additional 'div' for increased specificity. */
+        div.chip.selected {
+          border: 1px solid var(--selected-background);
+          background-color: var(--selected-background);
+          padding-left: calc(var(--spacing-m) + var(--thick-border) - 1px);
+        }
+        div.chip.selected .name,
+        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);
+          /* 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));
+        }
+        .attemptDetails {
+          padding-bottom: var(--spacing-s);
+        }
+        .attemptDetail {
+          /* This is thick-border (6) + spacing-m (8) + icon (20) + padding. */
+          padding-left: 39px;
+          padding-top: var(--spacing-s);
+        }
+        .attemptDetail input {
+          width: 14px;
+          height: 14px;
+          /* The next 3 are for placing in the middle of 20px line-height. */
+          vertical-align: top;
+          position: relative;
+          top: 3px;
+          margin-right: var(--spacing-s);
+        }
+      `,
+    ];
+  }
+
+  @query('.chip')
+  chipElement?: HTMLElement;
+
+  @property()
+  run!: CheckRun;
+
+  @property()
+  selected = false;
+
+  @property()
+  selectedAttempt?: number;
+
+  @property()
+  deselected = false;
+
+  @property()
+  shouldRender = false;
+
+  firstUpdated() {
+    assertIsDefined(this.chipElement, 'chip element');
+    whenVisible(
+      this.chipElement,
+      () => this.setAttribute('shouldRender', 'true'),
+      200
+    );
+  }
+
+  protected updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
+
+    // For some reason the browser does not pick up the correct `checked` state
+    // that is set in renderAttempt(). So we have to set it programmatically
+    // here.
+    const selectedAttempt = this.selectedAttempt ?? this.run.attempt;
+    const inputToBeSelected = this.shadowRoot?.querySelector(
+      `.attemptDetails input#attempt-${selectedAttempt}`
+    ) as HTMLInputElement | undefined;
+    if (inputToBeSelected) {
+      inputToBeSelected.checked = true;
+    }
+  }
+
+  render() {
+    if (!this.shouldRender) return html`<div class="chip">Loading ...</div>`;
+
+    const icon = iconForRun(this.run);
+    const classes = {
+      chip: true,
+      [icon]: true,
+      selected: this.selected,
+      deselected: this.deselected,
+    };
+    const action = primaryRunAction(this.run);
+
+    return html`
+      <div @click="${this.handleChipClick}" class="${classMap(classes)}">
+        <gr-hovercard-run .run="${this.run}"></gr-hovercard-run>
+        <div class="left">
+          ${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="right">
+          ${action
+            ? html`<gr-button
+                class="action"
+                link
+                @click="${(e: MouseEvent) => this.handleAction(e, action)}"
+                >${action.name}</gr-button
+              >`
+            : ''}
+        </div>
+      </div>
+      <div
+        class="attemptDetails"
+        ?hidden="${this.run.isSingleAttempt || !this.selected}"
+      >
+        ${this.run.attemptDetails.map(a => this.renderAttempt(a))}
+      </div>
+    `;
+  }
+
+  isSelected(detail: AttemptDetail) {
+    // this.selectedAttempt may be undefined, then choose the latest attempt,
+    // which is what this.run has.
+    const selectedAttempt = this.selectedAttempt ?? this.run.attempt;
+    return detail.attempt === selectedAttempt;
+  }
+
+  renderAttempt(detail: AttemptDetail) {
+    const checkNameId = charsOnly(this.run.checkName).toLowerCase();
+    const id = `attempt-${detail.attempt}`;
+    const icon = detail.icon;
+    return html`<div class="attemptDetail">
+      <input
+        type="radio"
+        id="${id}"
+        name="${`${checkNameId}-attempt-choice`}"
+        ?checked="${this.isSelected(detail)}"
+        @change="${() => this.handleAttemptChange(detail)}"
+      />
+      <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+      <label for="${id}">Attempt ${detail.attempt}</label>
+    </div>`;
+  }
+
+  handleAttemptChange(detail: AttemptDetail) {
+    if (!this.isSelected(detail)) {
+      fireAttemptSelected(this, this.run.checkName, detail.attempt);
+    }
+  }
+
+  renderFilterIcon() {
+    if (!this.selected) return;
+    return html`
+      <iron-icon class="filter" icon="gr-icons:filter"></iron-icon>
+    `;
+  }
+
+  /**
+   * For RUNNING we also want to render an icon representing the worst result
+   * that has been reported until now - if there are any results already.
+   */
+  renderAdditionalIcon() {
+    if (this.run.status !== RunStatus.RUNNING) return nothing;
+    const category = worstCategory(this.run);
+    if (!category) return nothing;
+    const icon = iconForCategory(category);
+    return html`
+      <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+    `;
+  }
+
+  private handleChipClick(e: MouseEvent) {
+    e.stopPropagation();
+    e.preventDefault();
+    fireRunSelected(this, this.run.checkName);
+  }
+
+  private handleAction(e: MouseEvent, action: Action) {
+    e.stopPropagation();
+    e.preventDefault();
+    fireActionTriggered(this, action, this.run);
+  }
+}
+
+@customElement('gr-checks-runs')
+export class GrChecksRuns extends GrLitElement {
+  @query('#filterInput')
+  filterInput?: HTMLInputElement;
+
+  @internalProperty()
+  filterRegExp = new RegExp('');
+
+  @property()
+  runs: CheckRun[] = [];
+
+  @property()
+  selectedRuns: string[] = [];
+
+  /** Maps checkName to selected attempt number. `undefined` means `latest`. */
+  @property()
+  selectedAttempts: Map<string, number | undefined> = new Map<
+    string,
+    number | undefined
+  >();
+
+  @property()
+  tabState?: ChecksTabState;
+
+  private isSectionExpanded = new Map<RunStatus, boolean>();
+
+  constructor() {
+    super();
+    this.subscribe('runs', allRuns$);
+  }
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          padding: var(--spacing-l) var(--spacing-xl) var(--spacing-xl)
+            var(--spacing-xl);
+        }
+        .expandIcon {
+          width: var(--line-height-h3);
+          height: var(--line-height-h3);
+        }
+        .sectionHeader {
+          padding-top: var(--spacing-l);
+          text-transform: capitalize;
+          cursor: default;
+        }
+        .sectionHeader h3 {
+          display: inline-block;
+        }
+        .collapsed .sectionRuns {
+          display: none;
+        }
+        .collapsed {
+          border-bottom: 1px solid var(--border-color);
+          padding-bottom: var(--spacing-m);
+        }
+        input#filterInput {
+          margin-top: var(--spacing-m);
+          padding: var(--spacing-s) var(--spacing-m);
+          width: 100%;
+        }
+        .testing {
+          margin-top: var(--spacing-xxl);
+          color: var(--deemphasized-text-color);
+        }
+        .testing gr-button {
+          min-width: 25px;
+        }
+        .testing * {
+          visibility: hidden;
+        }
+        .testing:hover * {
+          visibility: visible;
+        }
+      `,
+    ];
+  }
+
+  protected updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
+    if (changedProperties.has('tabState') && this.tabState) {
+      const {statusOrCategory} = this.tabState;
+      if (
+        statusOrCategory === RunStatus.RUNNING ||
+        statusOrCategory === RunStatus.RUNNABLE
+      ) {
+        this.updateComplete.then(() => {
+          const s = statusOrCategory.toString().toLowerCase();
+          const el = this.shadowRoot?.querySelector(`.${s} .sectionHeader`);
+          el?.scrollIntoView({block: 'center'});
+        });
+      }
+    }
+  }
+
+  render() {
+    return html`
+      <h2 class="heading-2">Runs</h2>
+      <input
+        id="filterInput"
+        type="text"
+        placeholder="Filter runs by regular expression"
+        ?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>
+      </div>
+    `;
+  }
+
+  onInput() {
+    assertIsDefined(this.filterInput, 'filter <input> element');
+    this.filterRegExp = new RegExp(this.filterInput.value, 'i');
+  }
+
+  none() {
+    updateStateSetResults('f0', [], []);
+    updateStateSetResults('f1', []);
+    updateStateSetResults('f2', []);
+    updateStateSetResults('f3', []);
+    updateStateSetResults('f4', []);
+  }
+
+  all() {
+    updateStateSetResults('f0', [fakeRun0], fakeActions, fakeLinks);
+    updateStateSetResults('f1', [fakeRun1]);
+    updateStateSetResults('f2', [fakeRun2]);
+    updateStateSetResults('f3', [fakeRun3]);
+    updateStateSetResults('f4', [
+      fakeRun4_1,
+      fakeRun4_2,
+      fakeRun4_3,
+      fakeRun4_4,
+    ]);
+  }
+
+  toggle(
+    plugin: string,
+    runs: CheckRun[],
+    actions: Action[] = [],
+    links: Link[] = []
+  ) {
+    const newRuns = this.runs.includes(runs[0]) ? [] : runs;
+    updateStateSetResults(plugin, newRuns, actions, links);
+  }
+
+  renderSection(status: RunStatus) {
+    const runs = this.runs
+      .filter(r => r.isLatestAttempt)
+      .filter(r => r.status === status)
+      .filter(r => this.filterRegExp.test(r.checkName))
+      .sort(compareByWorstCategory);
+    if (runs.length === 0) return;
+    const expanded = this.isSectionExpanded.get(status) ?? true;
+    const expandedClass = expanded ? 'expanded' : 'collapsed';
+    const icon = expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
+    return html`
+      <div class="${status.toLowerCase()} ${expandedClass}">
+        <div
+          class="sectionHeader"
+          @click="${() => this.toggleExpanded(status)}"
+        >
+          <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
+          <h3 class="heading-3">${status.toLowerCase()}</h3>
+        </div>
+        <div class="sectionRuns">${runs.map(run => this.renderRun(run))}</div>
+      </div>
+    `;
+  }
+
+  toggleExpanded(status: RunStatus) {
+    const expanded = this.isSectionExpanded.get(status) ?? true;
+    this.isSectionExpanded.set(status, !expanded);
+    this.requestUpdate();
+  }
+
+  renderRun(run: CheckRun) {
+    const selectedRun = this.selectedRuns.includes(run.checkName);
+    const selectedAttempt = this.selectedAttempts.get(run.checkName);
+    const deselected = !selectedRun && this.selectedRuns.length > 0;
+    return html`<gr-checks-run
+      .run="${run}"
+      .selected="${selectedRun}"
+      .selectedAttempt="${selectedAttempt}"
+      .deselected="${deselected}"
+    ></gr-checks-run>`;
+  }
+
+  showFilter(): boolean {
+    const show = this.runs.length > 10;
+    if (!show && this.filterRegExp.source.length > 0) {
+      this.filterRegExp = new RegExp('');
+    }
+    return show;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-checks-run': GrChecksRun;
+    'gr-checks-runs': GrChecksRuns;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
similarity index 69%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
index ac59f4f..4d54200 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
@@ -14,8 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+import '../../test/common-test-setup-karma';
+import {GrChecksRuns} from './gr-checks-runs';
+
+suite('gr-checks-runs test', () => {
+  test('is defined', () => {
+    const el = document.createElement('gr-checks-runs');
+    assert.instanceOf(el, GrChecksRuns);
+  });
+});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-styles.ts b/polygerrit-ui/app/elements/checks/gr-checks-styles.ts
new file mode 100644
index 0000000..9a1605e
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-styles.ts
@@ -0,0 +1,50 @@
+/**
+ * @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-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');
+
+export const checksStyles = css`
+  iron-icon.error {
+    color: var(--error-foreground);
+  }
+  iron-icon.warning {
+    color: var(--warning-foreground);
+  }
+  iron-icon.info-outline {
+    color: var(--info-foreground);
+  }
+  iron-icon.check-circle-outline {
+    color: var(--success-foreground);
+  }
+`;
+
+$_documentContainer.innerHTML = `<dom-module id="gr-checks-styles">
+  <template>
+    <style>
+    ${checksStyles.cssText}
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
new file mode 100644
index 0000000..418598b
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -0,0 +1,207 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the 'License');
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an 'AS IS' BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from 'lit-html';
+import {
+  css,
+  customElement,
+  internalProperty,
+  property,
+  PropertyValues,
+} from 'lit-element';
+import {GrLitElement} from '../lit/gr-lit-element';
+import {Action} from '../../api/checks';
+import {
+  CheckResult,
+  CheckRun,
+  allResults$,
+  allRuns$,
+  checksPatchsetNumber$,
+} from '../../services/checks/checks-model';
+import './gr-checks-runs';
+import './gr-checks-results';
+import {changeNum$} 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';
+
+/**
+ * The "Checks" tab on the Gerrit change page. Gets its data from plugins that
+ * have registered with the Checks Plugin API.
+ */
+@customElement('gr-checks-tab')
+export class GrChecksTab extends GrLitElement {
+  @property()
+  runs: CheckRun[] = [];
+
+  @property()
+  results: CheckResult[] = [];
+
+  @property()
+  tabState?: ChecksTabState;
+
+  @property()
+  checksPatchsetNumber: PatchSetNumber | undefined = undefined;
+
+  @property()
+  changeNum: NumericChangeId | undefined = undefined;
+
+  @internalProperty()
+  selectedRuns: string[] = [];
+
+  /** Maps checkName to selected attempt number. `undefined` means `latest`. */
+  @internalProperty()
+  selectedAttempts: Map<string, number | undefined> = new Map<
+    string,
+    number | undefined
+  >();
+
+  private readonly checksService = appContext.checksService;
+
+  constructor() {
+    super();
+    this.subscribe('runs', allRuns$);
+    this.subscribe('results', allResults$);
+    this.subscribe('checksPatchsetNumber', checksPatchsetNumber$);
+    this.subscribe('changeNum', changeNum$);
+
+    this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
+      this.handleActionTriggered(e.detail.action, e.detail.run)
+    );
+  }
+
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+      }
+      .container {
+        display: flex;
+      }
+      .runs {
+        min-width: 300px;
+        min-height: 400px;
+        border-right: 1px solid var(--border-color);
+      }
+      .results {
+        flex-grow: 1;
+      }
+    `;
+  }
+
+  render() {
+    return html`
+      <div class="container">
+        <gr-checks-runs
+          class="runs"
+          .runs="${this.runs}"
+          .selectedRuns="${this.selectedRuns}"
+          .selectedAttempts="${this.selectedAttempts}"
+          .tabState="${this.tabState}"
+          @run-selected="${this.handleRunSelected}"
+          @attempt-selected="${this.handleAttemptSelected}"
+        ></gr-checks-runs>
+        <gr-checks-results
+          class="results"
+          .tabState="${this.tabState}"
+          .runs="${this.runs}"
+          .selectedRuns="${this.selectedRuns}"
+          .selectedAttempts="${this.selectedAttempts}"
+          @run-selected="${this.handleRunSelected}"
+        ></gr-checks-results>
+      </div>
+    `;
+  }
+
+  protected updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
+    if (changedProperties.has('tabState')) {
+      if (this.tabState) {
+        this.selectedRuns = [];
+      }
+    }
+  }
+
+  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);
+        }
+      });
+  }
+
+  handleRunSelected(e: RunSelectedEvent) {
+    if (e.detail.reset) {
+      this.selectedRuns = [];
+      this.selectedAttempts = new Map();
+      return;
+    }
+    if (e.detail.checkName) {
+      this.toggleSelected(e.detail.checkName);
+    }
+  }
+
+  handleAttemptSelected(e: AttemptSelectedEvent) {
+    const {checkName, attempt} = e.detail;
+    this.selectedAttempts.set(checkName, attempt);
+    // Force property update.
+    this.selectedAttempts = new Map(this.selectedAttempts);
+  }
+
+  toggleSelected(checkName: string) {
+    if (this.selectedRuns.includes(checkName)) {
+      this.selectedRuns = this.selectedRuns.filter(r => r !== checkName);
+      this.selectedAttempts.set(checkName, undefined);
+      this.selectedAttempts = new Map(this.selectedAttempts);
+    } else {
+      this.selectedRuns = [...this.selectedRuns, checkName];
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-checks-tab': GrChecksTab;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
similarity index 70%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
index ac59f4f..85183ed 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
@@ -14,8 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+import '../../test/common-test-setup-karma';
+import {GrChecksTab} from './gr-checks-tab';
+
+suite('gr-checks-tab test', () => {
+  test('is defined', () => {
+    const el = document.createElement('gr-checks-tab');
+    assert.instanceOf(el, GrChecksTab);
+  });
+});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-util.ts b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
new file mode 100644
index 0000000..05a87a4
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
@@ -0,0 +1,87 @@
+/**
+ * @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 {CheckRun} from '../../services/checks/checks-model';
+
+export interface AttemptSelectedEventDetail {
+  checkName: string;
+  attempt: number | undefined;
+}
+
+export type AttemptSelectedEvent = CustomEvent<AttemptSelectedEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'attempt-selected': AttemptSelectedEvent;
+  }
+}
+
+export function fireAttemptSelected(
+  target: EventTarget,
+  checkName: string,
+  attempt: number | undefined
+) {
+  target.dispatchEvent(
+    new CustomEvent('attempt-selected', {
+      detail: {checkName, attempt},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export interface RunSelectedEventDetail {
+  reset: boolean;
+  checkName?: string;
+}
+
+export type RunSelectedEvent = CustomEvent<RunSelectedEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'run-selected': RunSelectedEvent;
+  }
+}
+
+export function fireRunSelected(target: EventTarget, checkName: string) {
+  target.dispatchEvent(
+    new CustomEvent('run-selected', {
+      detail: {reset: false, checkName},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export function fireRunSelectionReset(target: EventTarget) {
+  target.dispatchEvent(
+    new CustomEvent('run-selected', {
+      detail: {reset: true},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export function isAttemptSelected(
+  selectedAttempts: Map<string, number | undefined>,
+  run: CheckRun
+) {
+  const selected = selectedAttempts.get(run.checkName);
+  return (
+    (selected === undefined && run.isLatestAttempt) || selected === run.attempt
+  );
+}
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
new file mode 100644
index 0000000..ae6408d
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -0,0 +1,119 @@
+/**
+ * @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-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 {CheckRun} from '../../services/checks/checks-model';
+import {
+  iconForCategory,
+  iconForStatus,
+  runActions,
+  worstCategory,
+} from '../../services/checks/checks-util';
+import {durationString, fromNow} from '../../utils/date-util';
+import {RunStatus} from '../../api/checks';
+
+@customElement('gr-hovercard-run')
+export class GrHovercardRun extends hovercardBehaviorMixin(PolymerElement) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @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)
+      : '';
+  }
+
+  computeActions(run?: CheckRun) {
+    return runActions(run);
+  }
+
+  computeChipIcon(run?: CheckRun) {
+    if (run?.status === RunStatus.COMPLETED) return 'check';
+    if (run?.status === RunStatus.RUNNING) return 'timelapse';
+    return '';
+  }
+
+  computeCompletionDuration(run?: CheckRun) {
+    if (!run?.finishedTimestamp || !run?.startedTimestamp) return '';
+    return durationString(run.startedTimestamp, run.finishedTimestamp, true);
+  }
+
+  computeDuration(date?: Date) {
+    return date ? fromNow(date) : '';
+  }
+
+  computeHostName(link?: string) {
+    return link ? new URL(link).hostname : '';
+  }
+
+  hideChip(run?: CheckRun) {
+    return !run || run.status === RunStatus.RUNNABLE;
+  }
+
+  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)
+    );
+  }
+
+  hideAttempts(run?: CheckRun) {
+    const attemptCount = run?.attemptDetails?.length;
+    return attemptCount === undefined || attemptCount < 2;
+  }
+
+  hideScheduled(run?: CheckRun) {
+    return !run?.scheduledTimestamp || !!run?.startedTimestamp;
+  }
+
+  hideCompletion(run?: CheckRun) {
+    return !run?.startedTimestamp || !run?.finishedTimestamp;
+  }
+
+  hideDescriptionSection(run?: CheckRun) {
+    if (!run) return true;
+    return !run.checkLink && !run.checkDescription;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-hovercard-run': GrHovercardRun;
+  }
+}
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
new file mode 100644
index 0000000..08ceefe
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
@@ -0,0 +1,208 @@
+/**
+ * @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.js
new file mode 100644
index 0000000..9c77654
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.js
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import './gr-hovercard-run.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-hovercard-run class="hovered"></gr-hovercard-run>
+`);
+
+suite('gr-hovercard-run tests', () => {
+  let element;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await flush();
+  });
+
+  teardown(() => {
+    element.hide({});
+  });
+
+  test('hovercard is shown', () => {
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
index ef0ced8..198f1d9 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -16,17 +16,15 @@
  */
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dropdown/gr-dropdown';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import '../../shared/gr-avatar/gr-avatar';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 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 {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
 
 const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
 
@@ -36,16 +34,8 @@
   }
 }
 
-export interface GrAccountDropdown {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 @customElement('gr-account-dropdown')
-export class GrAccountDropdown extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAccountDropdown extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -71,12 +61,14 @@
   @property({type: String})
   _switchAccountUrl = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
-    this._handleLocationChange();
-    this.listen(window, 'location-change', '_handleLocationChange');
-    this.$.restAPI.getConfig().then(cfg => {
+  connectedCallback() {
+    super.connectedCallback();
+    this.handleLocationChange();
+    window.addEventListener('location-change', this.handleLocationChange);
+    this.restApiService.getConfig().then(cfg => {
       this.config = cfg;
 
       if (cfg && cfg.auth && cfg.auth.switch_account_url) {
@@ -89,9 +81,9 @@
   }
 
   /** @override */
-  detached() {
-    super.detached();
-    this.unlisten(window, 'location-change', '_handleLocationChange');
+  disconnectedCallback() {
+    window.removeEventListener('location-change', this.handleLocationChange);
+    super.disconnectedCallback();
   }
 
   _getLinks(switchAccountUrl: string, path: string) {
@@ -120,18 +112,13 @@
   }
 
   _handleShortcutsTap() {
-    this.dispatchEvent(
-      new CustomEvent('show-keyboard-shortcuts', {
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireEvent(this, 'show-keyboard-shortcuts');
   }
 
-  _handleLocationChange() {
+  private readonly handleLocationChange = () => {
     this._path =
       window.location.pathname + window.location.search + window.location.hash;
-  }
+  };
 
   _interpolateUrl(url: string, replacements: {[key: string]: string}) {
     return url.replace(
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
index b67e1e8..0fa2f1e 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
@@ -49,5 +49,4 @@
       aria-label="Account avatar"
     ></gr-avatar>
   </gr-dropdown>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js
index a8f206c..d2d27b7 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js
@@ -24,9 +24,6 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
     element = basicFixture.instantiate();
   });
 
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
index b28b13e..63b5bc8 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
@@ -16,8 +16,6 @@
  */
 import '../../shared/gr-dialog/gr-dialog';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-error-dialog_html';
 import {customElement, property} from '@polymer/decorators';
@@ -29,9 +27,7 @@
 }
 
 @customElement('gr-error-dialog')
-export class GrErrorDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrErrorDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.js b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
similarity index 63%
rename from polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.js
rename to polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
index ea8f7c5..fa57a23 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.js
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
@@ -15,21 +15,25 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-error-dialog.js';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import '../../../test/common-test-setup-karma';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrErrorDialog} from './gr-error-dialog';
 
 const basicFixture = fixtureFromElement('gr-error-dialog');
 
 suite('gr-error-dialog tests', () => {
-  let element;
+  let element: GrErrorDialog;
 
   setup(() => {
     element = basicFixture.instantiate();
   });
 
   test('dismiss tap fires event', done => {
-    element.addEventListener('dismiss', () => { done(); });
-    MockInteractions.tap(element.$.dialog.$.confirm);
+    element.addEventListener('dismiss', () => done());
+    MockInteractions.tap(
+      (queryAndAssert(element, '#dialog') as GrDialog).$.confirm
+    );
   });
 });
-
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 7a56d1c..3a6a9f6 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -19,26 +19,27 @@
 import '../gr-error-dialog/gr-error-dialog';
 import '../../shared/gr-alert/gr-alert';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-error-manager_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {appContext} from '../../../services/app-context';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {customElement, property} from '@polymer/decorators';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {AuthService} from '../../../services/gr-auth/gr-auth';
-import {EventEmitterService} from '../../../services/gr-event-interface/gr-event-interface';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrErrorDialog} from '../gr-error-dialog/gr-error-dialog';
 import {GrAlert} from '../../shared/gr-alert/gr-alert';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {FetchRequest} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {ErrorType, FixIronA11yAnnouncer} from '../../../types/types';
 import {AccountId} from '../../../types/common';
+import {
+  EventType,
+  NetworkErrorEvent,
+  ServerErrorEvent,
+  ShowAlertEventDetail,
+  ShowErrorEvent,
+} from '../../../types/events';
+import {windowLocationReload} from '../../../utils/dom-util';
+import {debounce, DelayedTask} from '../../../utils/async-util';
+import {fireIronAnnounce} from '../../../utils/event-util';
 
 const HIDE_ALERT_TIMEOUT_MS = 5000;
 const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
@@ -46,6 +47,10 @@
 const SIGN_IN_WIDTH_PX = 690;
 const SIGN_IN_HEIGHT_PX = 500;
 const TOO_MANY_FILES = 'too many files to find conflicts';
+/* TODO: This error is suppressed to allow rolling upgrades.
+ * Remove on stable-3.6 */
+const CONFLICTS_OPERATOR_IS_NOT_SUPPORTED =
+  "'conflicts:' operator is not supported by server";
 const AUTHENTICATION_REQUIRED = 'Authentication required\n';
 
 // Bigger number has higher priority
@@ -71,13 +76,11 @@
     noInteractionOverlay: GrOverlay;
     errorDialog: GrErrorDialog;
     errorOverlay: GrOverlay;
-    restAPI: RestApiService & Element;
   };
 }
+
 @customElement('gr-error-manager')
-export class GrErrorManager extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrErrorManager extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -107,33 +110,28 @@
   @property({type: String})
   loginUrl = '/login';
 
-  reporting: ReportingService;
+  private readonly reporting = appContext.reportingService;
 
-  _authService: AuthService;
+  private readonly _authService = appContext.authService;
 
-  eventEmitter: EventEmitterService;
+  private readonly eventEmitter = appContext.eventEmitter;
 
   _authErrorHandlerDeregistrationHook?: Function;
 
-  constructor() {
-    super();
+  private readonly restApiService = appContext.restApiService;
 
-    this._authService = appContext.authService;
-
-    this.reporting = appContext.reportingService;
-    this.eventEmitter = appContext.eventEmitter;
-  }
+  private checkLoggedInTask?: DelayedTask;
 
   /** @override */
-  attached() {
-    super.attached();
-    this.listen(document, 'server-error', '_handleServerError');
-    this.listen(document, 'network-error', '_handleNetworkError');
-    this.listen(document, 'show-alert', '_handleShowAlert');
-    this.listen(document, 'hide-alert', '_hideAlert');
-    this.listen(document, 'show-error', '_handleShowErrorDialog');
-    this.listen(document, 'visibilitychange', '_handleVisibilityChange');
-    this.listen(document, 'show-auth-required', '_handleAuthRequired');
+  connectedCallback() {
+    super.connectedCallback();
+    document.addEventListener(EventType.SERVER_ERROR, this.handleServerError);
+    document.addEventListener(EventType.NETWORK_ERROR, this.handleNetworkError);
+    document.addEventListener(EventType.SHOW_ALERT, this.handleShowAlert);
+    document.addEventListener('hide-alert', this.hideAlert);
+    document.addEventListener('show-error', this.handleShowErrorDialog);
+    document.addEventListener('visibilitychange', this.handleVisibilityChange);
+    document.addEventListener('show-auth-required', this.handleAuthRequired);
 
     this._authErrorHandlerDeregistrationHook = this.eventEmitter.on(
       'auth-error',
@@ -146,32 +144,45 @@
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     this._clearHideAlertHandle();
-    this.unlisten(document, 'server-error', '_handleServerError');
-    this.unlisten(document, 'network-error', '_handleNetworkError');
-    this.unlisten(document, 'show-alert', '_handleShowAlert');
-    this.unlisten(document, 'hide-alert', '_hideAlert');
-    this.unlisten(document, 'show-error', '_handleShowErrorDialog');
-    this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
-    this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
+    document.removeEventListener(
+      EventType.SERVER_ERROR,
+      this.handleServerError
+    );
+    document.removeEventListener(
+      EventType.NETWORK_ERROR,
+      this.handleNetworkError
+    );
+    document.removeEventListener(EventType.SHOW_ALERT, this.handleShowAlert);
+    document.removeEventListener('hide-alert', this.hideAlert);
+    document.removeEventListener('show-error', this.handleShowErrorDialog);
+    document.removeEventListener(
+      'visibilitychange',
+      this.handleVisibilityChange
+    );
+    document.removeEventListener('show-auth-required', this.handleAuthRequired);
+    this.checkLoggedInTask?.cancel();
 
     if (this._authErrorHandlerDeregistrationHook) {
       this._authErrorHandlerDeregistrationHook();
     }
+    super.disconnectedCallback();
   }
 
   _shouldSuppressError(msg: string) {
-    return msg.includes(TOO_MANY_FILES);
+    return (
+      msg.includes(TOO_MANY_FILES) ||
+      msg.includes(CONFLICTS_OPERATOR_IS_NOT_SUPPORTED)
+    );
   }
 
-  _handleAuthRequired() {
+  private readonly handleAuthRequired = () => {
     this._showAuthErrorAlert(
       'Log in is required to perform that action.',
       'Log in.'
     );
-  }
+  };
 
   _handleAuthError(msg: string, action: string) {
     this.$.noInteractionOverlay.open().then(() => {
@@ -179,9 +190,7 @@
     });
   }
 
-  _handleServerError(
-    e: CustomEvent<{response: Response; request: FetchRequest}>
-  ) {
+  private readonly handleServerError = (e: ServerErrorEvent) => {
     const {request, response} = e.detail;
     response.text().then(errorText => {
       const url = request && (request.anonymizedUrl || request.url);
@@ -193,7 +202,7 @@
       ) {
         // if not authed previously, this is trying to access auth required APIs
         // show auth required alert
-        this._handleAuthRequired();
+        this.handleAuthRequired();
       } else if (
         response.status === 403 &&
         this._authService.isAuthed &&
@@ -203,7 +212,7 @@
         // This indicates the auth token may no longer valid.
         // Re-check on auth
         this._authService.clearCache();
-        this.$.restAPI.getLoggedIn();
+        this.restApiService.getLoggedIn();
       } else if (!this._shouldSuppressError(errorText)) {
         const trace =
           response.headers && response.headers.get('X-Gerrit-Trace');
@@ -229,7 +238,7 @@
       }
       console.info(`server error: ${errorText}`);
     });
-  }
+  };
 
   _showNotFoundMessageWithTip({
     status,
@@ -238,7 +247,7 @@
     url,
     trace,
   }: ErrorMsg) {
-    this.$.restAPI.getLoggedIn().then(isLoggedIn => {
+    this.restApiService.getLoggedIn().then(isLoggedIn => {
       const tip = isLoggedIn
         ? 'You might have not enough privileges.'
         : 'You might have not enough privileges. Sign in and try again.';
@@ -289,19 +298,21 @@
     return err;
   }
 
-  _handleShowAlert(e: CustomEvent) {
+  private readonly handleShowAlert = (e: CustomEvent<ShowAlertEventDetail>) => {
     this._showAlert(
       e.detail.message,
       e.detail.action,
       e.detail.callback,
-      e.detail.dismissOnNavigation
+      e.detail.dismissOnNavigation,
+      undefined,
+      e.detail.showDismiss
     );
-  }
+  };
 
-  _handleNetworkError(e: CustomEvent) {
+  private readonly handleNetworkError = (e: NetworkErrorEvent) => {
     this._showAlert('Server unavailable');
     console.error(e.detail.error.message);
-  }
+  };
 
   // TODO(dhruvsr): allow less priority alerts to override high priority alerts
   // In some use cases we may want generic alerts to show along/over errors
@@ -314,32 +325,33 @@
     actionText?: string,
     actionCallback?: () => void,
     dismissOnNavigation?: boolean,
-    type?: ErrorType
+    type?: ErrorType,
+    showDismiss?: boolean
   ) {
     if (this._alertElement) {
       // check priority before hiding
       if (!this._canOverride(type, this._alertElement.type)) return;
-      this._hideAlert();
+      this.hideAlert();
     }
 
     this._clearHideAlertHandle();
     if (dismissOnNavigation) {
       // Persist alert until navigation.
-      this.listen(document, 'location-change', '_hideAlert');
+      document.addEventListener('location-change', this.hideAlert);
     } else {
-      this._hideAlertHandle = this.async(
-        this._hideAlert,
+      this._hideAlertHandle = window.setTimeout(
+        this.hideAlert,
         HIDE_ALERT_TIMEOUT_MS
       );
     }
-    const el = this._createToastAlert();
+    const el = this._createToastAlert(showDismiss);
     el.show(text, actionText, actionCallback);
     this._alertElement = el;
-    this.fire('iron-announce', {text}, {bubbles: true});
+    fireIronAnnounce(this, `Alert: ${text}`);
     this.reporting.reportInteraction('show-alert', {text});
   }
 
-  _hideAlert() {
+  private readonly hideAlert = () => {
     if (!this._alertElement) {
       return;
     }
@@ -348,12 +360,12 @@
     this._alertElement = null;
 
     // Remove listener for page navigation, if it exists.
-    this.unlisten(document, 'location-change', '_hideAlert');
-  }
+    document.removeEventListener('location-change', this.hideAlert);
+  };
 
   _clearHideAlertHandle() {
     if (this._hideAlertHandle !== null) {
-      this.cancelAsync(this._hideAlertHandle);
+      window.clearTimeout(this._hideAlertHandle);
       this._hideAlertHandle = null;
     }
   }
@@ -370,26 +382,25 @@
     this._alertElement.show(errorText, actionText, () =>
       this._createLoginPopup()
     );
-    this.fire('iron-announce', {text: errorText}, {bubbles: true});
+    fireIronAnnounce(this, errorText);
+    this.reporting.reportInteraction('show-auth-error', {text: errorText});
     this._refreshingCredentials = true;
     this._requestCheckLoggedIn();
     if (!document.hidden) {
-      this._handleVisibilityChange();
+      this.handleVisibilityChange();
     }
   }
 
-  _createToastAlert() {
+  _createToastAlert(showDismiss?: boolean) {
     const el = document.createElement('gr-alert');
     el.toast = true;
+    el.showDismiss = !!showDismiss;
     return el;
   }
 
-  _handleVisibilityChange() {
-    // Ignore when the page is transitioning to hidden (or hidden is
-    // undefined).
-    if (document.hidden !== false) {
-      return;
-    }
+  private readonly handleVisibilityChange = () => {
+    // Ignore when the page is transitioning to hidden (or hidden is undefined).
+    if (document.hidden !== false) return;
 
     // If not currently refreshing credentials and the credentials are old,
     // request them to confirm their validity or (display an auth toast if it
@@ -400,6 +411,7 @@
       this.knownAccountId !== undefined &&
       timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS
     ) {
+      this.reporting.reportInteraction('visibility-sign-in-check');
       this._lastCredentialCheck = Date.now();
 
       // check auth status in case:
@@ -407,12 +419,12 @@
       // - user switched account
       this._checkSignedIn();
     }
-  }
+  };
 
   _requestCheckLoggedIn() {
-    this.debounce(
-      'checkLoggedIn',
-      this._checkSignedIn,
+    this.checkLoggedInTask = debounce(
+      this.checkLoggedInTask,
+      () => this._checkSignedIn(),
       CHECK_SIGN_IN_INTERVAL_MS
     );
   }
@@ -421,11 +433,10 @@
     this._lastCredentialCheck = Date.now();
 
     // force to refetch account info
-    this.$.restAPI.invalidateAccountsCache();
+    this.restApiService.invalidateAccountsCache();
     this._authService.clearCache();
 
-    this.$.restAPI.getLoggedIn().then(isLoggedIn => {
-      // do nothing if its refreshing
+    this.restApiService.getLoggedIn().then(isLoggedIn => {
       if (!this._refreshingCredentials) return;
 
       if (!isLoggedIn) {
@@ -435,17 +446,20 @@
         // in case #2, auth-error is taken care of separately
         this._requestCheckLoggedIn();
       } else {
-        // check account
-        this.$.restAPI.getAccount().then(account => {
+        this.restApiService.getAccount().then(account => {
           if (this._refreshingCredentials) {
-            // If the credentials were refreshed but the account is different
+            // If the credentials were refreshed but the account is different,
             // then reload the page completely.
             if (account?._account_id !== this.knownAccountId) {
+              this.reporting.reportInteraction('sign-in-window-reload', {
+                oldAccount: !!this.knownAccountId,
+                newAccount: !!account?._account_id,
+              });
               this._reloadPage();
               return;
             }
 
-            this._handleCredentialRefreshed();
+            this.handleCredentialRefreshed();
           }
         });
       }
@@ -453,7 +467,7 @@
   }
 
   _reloadPage() {
-    window.location.reload();
+    windowLocationReload();
   }
 
   _createLoginPopup() {
@@ -470,13 +484,13 @@
       '_blank',
       options.join(',')
     );
-    this.listen(window, 'focus', '_handleWindowFocus');
+    window.addEventListener('focus', this.handleWindowFocus);
   }
 
-  _handleCredentialRefreshed() {
-    this.unlisten(window, 'focus', '_handleWindowFocus');
+  handleCredentialRefreshed() {
+    window.removeEventListener('focus', this.handleWindowFocus);
     this._refreshingCredentials = false;
-    this._hideAlert();
+    this.hideAlert();
     this._showAlert('Credentials refreshed.');
     this.$.noInteractionOverlay.close();
 
@@ -484,13 +498,13 @@
     this._authService.clearCache();
   }
 
-  _handleWindowFocus() {
-    this.flushDebouncer('checkLoggedIn');
-  }
+  private readonly handleWindowFocus = () => {
+    this.checkLoggedInTask?.flush();
+  };
 
-  _handleShowErrorDialog(e: CustomEvent) {
+  private readonly handleShowErrorDialog = (e: ShowErrorEvent) => {
     this._showErrorDialog(e.detail.message);
-  }
+  };
 
   _handleDismissErrorDialog() {
     this.$.errorOverlay.close();
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts
index 1cefb78..c67ed07 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts
@@ -34,5 +34,4 @@
     no-cancel-on-outside-click=""
   >
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
index f13276f..d1283a9 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
@@ -19,6 +19,8 @@
 import './gr-error-manager.js';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 import {__testOnly_ErrorType} from './gr-error-manager.js';
+import {stubRestApi} from '../../../test/test-utils.js';
+import {appContext} from '../../../services/app-context.js';
 
 const basicFixture = fixtureFromElement('gr-error-manager');
 
@@ -30,10 +32,14 @@
   suite('when authed', () => {
     let toastSpy;
     let openOverlaySpy;
+    let fetchStub;
+    let getLoggedInStub;
 
     setup(() => {
-      sinon.stub(window, 'fetch')
+      fetchStub = sinon.stub(window, 'fetch')
           .returns(Promise.resolve({ok: true, status: 204}));
+      getLoggedInStub = stubRestApi('getLoggedIn')
+          .callsFake(() => appContext.authService.authCheck());
       element = basicFixture.instantiate();
       element._authService.clearCache();
       toastSpy = sinon.spy(element, '_createToastAlert');
@@ -67,8 +73,7 @@
               element, '_showAuthErrorAlert'
           );
           const responseText = Promise.resolve('Authentication required\n');
-          sinon.stub(element.$.restAPI, 'getLoggedIn')
-              .returns(Promise.resolve(true));
+          getLoggedInStub.returns(Promise.resolve(true));
           element.dispatchEvent(
               new CustomEvent('server-error', {
                 detail:
@@ -81,36 +86,33 @@
           });
         });
 
-    test('recheck auth for 403 with auth error if authed before', done => {
-      // starts with authed state
-      element.$.restAPI.getLoggedIn();
+    test('recheck auth for 403 with auth error if authed before', async () => {
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
       const responseText = Promise.resolve('Authentication required\n');
-      sinon.stub(element.$.restAPI, 'getLoggedIn')
-          .returns(Promise.resolve(true));
+      getLoggedInStub.returns(Promise.resolve(true));
       element.dispatchEvent(
           new CustomEvent('server-error', {
             detail:
           {response: {status: 403, text() { return responseText; }}},
             composed: true, bubbles: true,
           }));
-      flush(() => {
-        assert.isTrue(element.$.restAPI.getLoggedIn.calledOnce);
-        done();
-      });
+      await flush();
+      assert.isTrue(getLoggedInStub.calledOnce);
     });
 
     test('show logged in error', () => {
-      sinon.stub(element, '_showAuthErrorAlert');
+      const spy = sinon.spy(element, '_showAuthErrorAlert');
       element.dispatchEvent(
           new CustomEvent('show-auth-required', {
             composed: true, bubbles: true,
           }));
-      assert.isTrue(element._showAuthErrorAlert.calledWithExactly(
+      assert.isTrue(spy.calledWithExactly(
           'Log in is required to perform that action.', 'Log in.'));
     });
 
     test('show normal Error', done => {
-      const showErrorStub = sinon.stub(element, '_showErrorDialog');
+      const showErrorSpy = sinon.spy(element, '_showErrorDialog');
       const textSpy = sinon.spy(() => Promise.resolve('ZOMG'));
       element.dispatchEvent(
           new CustomEvent('server-error', {
@@ -120,8 +122,8 @@
 
       assert.isTrue(textSpy.called);
       flush(() => {
-        assert.isTrue(showErrorStub.calledOnce);
-        assert.isTrue(showErrorStub.lastCall.calledWithExactly(
+        assert.isTrue(showErrorSpy.calledOnce);
+        assert.isTrue(showErrorSpy.lastCall.calledWithExactly(
             'Error 500: ZOMG'));
         done();
       });
@@ -201,6 +203,26 @@
       });
     });
 
+    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');
@@ -240,26 +262,27 @@
     });
 
     test('show auth refresh toast', async () => {
-      // starts with authed state
-      element.$.restAPI.getLoggedIn();
-      const refreshStub = sinon.stub(element.$.restAPI, 'getAccount').callsFake(
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
+      const refreshStub = stubRestApi(
+          'getAccount').callsFake(
           () => Promise.resolve({}));
       const windowOpen = sinon.stub(window, 'open');
       const responseText = Promise.resolve('Authentication required\n');
       // fake failed auth
-      window.fetch.returns(Promise.resolve({status: 403}));
+      fetchStub.returns(Promise.resolve({status: 403}));
       element.dispatchEvent(
           new CustomEvent('server-error', {
             detail:
           {response: {status: 403, text() { return responseText; }}},
             composed: true, bubbles: true,
           }));
-      assert.equal(window.fetch.callCount, 1);
+      assert.equal(fetchStub.callCount, 1);
       await flush();
 
       // here needs two flush as there are two chanined
       // promises on server-error handler and flush only flushes one
-      assert.equal(window.fetch.callCount, 2);
+      assert.equal(fetchStub.callCount, 2);
       await flush();
       // Sometime overlay opens with delay, waiting while open is complete
       await openOverlaySpy.lastCall.returnValue;
@@ -293,9 +316,9 @@
       const hideToastSpy = sinon.spy(toast, 'hide');
 
       // now fake authed
-      window.fetch.returns(Promise.resolve({status: 204}));
-      element._handleWindowFocus();
-      element.flushDebouncer('checkLoggedIn');
+      fetchStub.returns(Promise.resolve({status: 204}));
+      element.handleWindowFocus();
+      element.checkLoggedInTask.flush();
       await flush();
       assert.isTrue(refreshStub.called);
       assert.isTrue(hideToastSpy.called);
@@ -312,8 +335,8 @@
     });
 
     test('auth toast should dismiss existing toast', async () => {
-      // starts with authed state
-      element.$.restAPI.getLoggedIn();
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
       const responseText = Promise.resolve('Authentication required\n');
 
       // fake an alert
@@ -328,18 +351,18 @@
           toast.root.textContent, 'test reload');
 
       // fake auth
-      window.fetch.returns(Promise.resolve({status: 403}));
+      fetchStub.returns(Promise.resolve({status: 403}));
       element.dispatchEvent(
           new CustomEvent('server-error', {
             detail:
           {response: {status: 403, text() { return responseText; }}},
             composed: true, bubbles: true,
           }));
-      assert.equal(window.fetch.callCount, 1);
+      assert.equal(fetchStub.callCount, 1);
       await flush();
       // here needs two flush as there are two chained
       // promises on server-error handler and flush only flushes one
-      assert.equal(window.fetch.callCount, 2);
+      assert.equal(fetchStub.callCount, 2);
       await flush();
       // Sometime overlay opens with delay, waiting while open is complete
       await openOverlaySpy.lastCall.returnValue;
@@ -352,8 +375,8 @@
     });
 
     test('regular toast should dismiss regular toast', () => {
-      // starts with authed state
-      element.$.restAPI.getLoggedIn();
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
 
       // fake an alert
       element.dispatchEvent(
@@ -378,23 +401,23 @@
     });
 
     test('regular toast should not dismiss auth toast', done => {
-      // starts with authed state
-      element.$.restAPI.getLoggedIn();
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
       const responseText = Promise.resolve('Authentication required\n');
 
       // fake auth
-      window.fetch.returns(Promise.resolve({status: 403}));
+      fetchStub.returns(Promise.resolve({status: 403}));
       element.dispatchEvent(
           new CustomEvent('server-error', {
             detail:
           {response: {status: 403, text() { return responseText; }}},
             composed: true, bubbles: true,
           }));
-      assert.equal(window.fetch.callCount, 1);
+      assert.equal(fetchStub.callCount, 1);
       flush(() => {
         // here needs two flush as there are two chained
         // promises on server-error handler and flush only flushes one
-        assert.equal(window.fetch.callCount, 2);
+        assert.equal(fetchStub.callCount, 2);
         flush(() => {
           let toast = toastSpy.lastCall.returnValue;
           assert.include(
@@ -440,14 +463,14 @@
           '_checkSignedIn');
       sinon.stub(Date, 'now').returns(999999);
       element._lastCredentialCheck = 0;
-      element._handleVisibilityChange();
+      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();
+      element.handleVisibilityChange();
 
       // Should test credentials, since there is a known account.
       assert.isTrue(refreshStub.called);
@@ -456,11 +479,11 @@
 
     test('refreshes with same credentials', done => {
       const accountPromise = Promise.resolve({_account_id: 1234});
-      sinon.stub(element.$.restAPI, 'getAccount')
+      stubRestApi('getAccount')
           .returns(accountPromise);
       const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
       const handleRefreshStub = sinon.stub(element,
-          '_handleCredentialRefreshed');
+          'handleCredentialRefreshed');
       const reloadStub = sinon.stub(element, '_reloadPage');
 
       element.knownAccountId = 1234;
@@ -477,7 +500,7 @@
 
     test('_showAlert hides existing alerts', () => {
       element._alertElement = element._createToastAlert();
-      const hideStub = sinon.stub(element, '_hideAlert');
+      const hideStub = sinon.stub(element, 'hideAlert');
       element._showAlert();
       assert.isTrue(hideStub.calledOnce);
     });
@@ -513,13 +536,13 @@
 
     test('reloads when refreshed credentials differ', done => {
       const accountPromise = Promise.resolve({_account_id: 1234});
-      sinon.stub(element.$.restAPI, 'getAccount')
+      stubRestApi('getAccount')
           .returns(accountPromise);
       const requestCheckStub = sinon.stub(
           element,
           '_requestCheckLoggedIn');
       const handleRefreshStub = sinon.stub(element,
-          '_handleCredentialRefreshed');
+          'handleCredentialRefreshed');
       const reloadStub = sinon.stub(element, '_reloadPage');
 
       element.knownAccountId = 4321; // Different from 1234
@@ -538,9 +561,7 @@
   suite('when not authed', () => {
     let toastSpy;
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-      });
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       element = basicFixture.instantiate();
       toastSpy = sinon.spy(element, '_createToastAlert');
     });
@@ -556,7 +577,7 @@
           element,
           '_requestCheckLoggedIn');
       const handleRefreshStub = sinon.stub(element,
-          '_handleCredentialRefreshed');
+          'handleCredentialRefreshed');
       const reloadStub = sinon.stub(element, '_reloadPage');
 
       element._refreshingCredentials = true;
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts
similarity index 61%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts
index ac59f4f..ccba289 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts
@@ -14,8 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {css} from 'lit-element';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
+export const cssTemplate = css`
+  .key {
+    background-color: var(--chip-background-color);
+    color: var(--primary-text-color);
+    border: 1px solid var(--border-color);
+    border-radius: var(--border-radius);
+    display: inline-block;
+    font-weight: var(--font-weight-bold);
+    padding: var(--spacing-xxs) var(--spacing-m);
+    text-align: center;
+  }
 `;
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
index 796a167..091684f 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
@@ -14,12 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-key-binding-display_html';
-import {customElement, property} from '@polymer/decorators';
+import {html} from 'lit-html';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {customElement, property} from 'lit-element';
+import {cssTemplate} from './gr-key-binding-display.css';
+import {sharedStyles} from '../../../styles/shared-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -28,21 +27,30 @@
 }
 
 @customElement('gr-key-binding-display')
-export class GrKeyBindingDisplay extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
-  static get template() {
-    return htmlTemplate;
+export class GrKeyBindingDisplay extends GrLitElement {
+  static get styles() {
+    return [sharedStyles, cssTemplate];
+  }
+
+  render() {
+    const items = this.binding.map((binding, index) => [
+      index > 0 ? html` or ` : html``,
+      this._computeModifiers(binding).map(
+        modifier => html`<span class="key modifier">${modifier}</span> `
+      ),
+      html`<span class="key">${this._computeKey(binding)}</span>`,
+    ]);
+    return html`${items}`;
   }
 
   @property({type: Array})
   binding: string[][] = [];
 
-  _computeModifiers(binding: string[][]) {
+  _computeModifiers(binding: string[]) {
     return binding.slice(0, binding.length - 1);
   }
 
-  _computeKey(binding: string[][]) {
+  _computeKey(binding: string[]) {
     return binding[binding.length - 1];
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts
deleted file mode 100644
index 0a75104..0000000
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .key {
-      background-color: var(--chip-background-color);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      display: inline-block;
-      font-weight: var(--font-weight-bold);
-      padding: var(--spacing-xxs) var(--spacing-m);
-      text-align: center;
-    }
-  </style>
-  <template is="dom-repeat" items="[[binding]]">
-    <template is="dom-if" if="[[index]]">
-      or
-    </template>
-    <template is="dom-repeat" items="[[_computeModifiers(item)]]" as="modifier">
-      <span class="key modifier">[[modifier]]</span>
-    </template>
-    <span class="key">[[_computeKey(item)]]</span>
-  </template>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.js b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
similarity index 82%
rename from polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.js
rename to polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
index 0c25e6e..dd023cb 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.js
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
@@ -15,13 +15,14 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-key-binding-display.js';
+import '../../../test/common-test-setup-karma';
+import './gr-key-binding-display';
+import {GrKeyBindingDisplay} from './gr-key-binding-display';
 
 const basicFixture = fixtureFromElement('gr-key-binding-display');
 
 suite('gr-key-binding-display tests', () => {
-  let element;
+  let element: GrKeyBindingDisplay;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -45,10 +46,10 @@
 
     test('key with modifiers', () => {
       assert.deepEqual(element._computeModifiers(['Ctrl', 'x']), ['Ctrl']);
-      assert.deepEqual(
-          element._computeModifiers(['Shift', 'Meta', 'x']),
-          ['Shift', 'Meta']);
+      assert.deepEqual(element._computeModifiers(['Shift', 'Meta', 'x']), [
+        'Shift',
+        'Meta',
+      ]);
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
index 4bd90ea..0ec9b39 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,8 +17,6 @@
 import '../../shared/gr-button/gr-button';
 import '../gr-key-binding-display/gr-key-binding-display';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-keyboard-shortcuts-dialog_html';
 import {
@@ -42,7 +40,7 @@
 
 @customElement('gr-keyboard-shortcuts-dialog')
 export class GrKeyboardShortcutsDialog extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  PolymerElement
 ) {
   static get template() {
     return htmlTemplate;
@@ -76,19 +74,19 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.addKeyboardShortcutDirectoryListener(
       this.keyboardShortcutDirectoryListener
     );
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     this.removeKeyboardShortcutDirectoryListener(
       this.keyboardShortcutDirectoryListener
     );
+    super.disconnectedCallback();
   }
 
   _handleCloseTap(e: MouseEvent) {
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts
index 1860f38..3576dfe 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts
@@ -30,21 +30,39 @@
       display: flex;
       padding: 0 var(--spacing-xxl) var(--spacing-xxl);
     }
+    .column {
+      flex: 50%;
+    }
     header {
       align-items: center;
       border-bottom: 1px solid var(--border-color);
       display: flex;
       justify-content: space-between;
     }
-    table:last-of-type {
-      margin-left: var(--spacing-xxl);
+    table caption {
+      font-weight: var(--font-weight-bold);
+      padding-top: var(--spacing-l);
+      text-align: left;
+    }
+    tr {
+      height: 32px;
     }
     td {
       padding: var(--spacing-xs) 0;
     }
-    td:first-child {
+    td:first-child,
+    th:first-child {
       padding-right: var(--spacing-m);
       text-align: right;
+      width: 160px;
+      color: var(--deemphasized-text-color);
+    }
+    td:second-child {
+      min-width: 200px;
+    }
+    th {
+      color: var(--deemphasized-text-color);
+      text-align: left;
     }
     .header {
       font-weight: var(--font-weight-bold);
@@ -59,33 +77,19 @@
     <gr-button link="" on-click="_handleCloseTap">Close</gr-button>
   </header>
   <main>
-    <table>
-      <tbody>
-        <template is="dom-repeat" items="[[_left]]">
-          <tr>
-            <td></td>
-            <td class="header">[[item.section]]</td>
-          </tr>
-          <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
+    <div class="column">
+      <template is="dom-repeat" items="[[_left]]">
+        <table>
+          <caption>
+            [[item.section]]
+          </caption>
+          <thead>
             <tr>
-              <td>
-                <gr-key-binding-display binding="[[shortcut.binding]]">
-                </gr-key-binding-display>
-              </td>
-              <td>[[shortcut.text]]</td>
+              <th>Key</th>
+              <th>Action</th>
             </tr>
-          </template>
-        </template>
-      </tbody>
-    </table>
-    <template is="dom-if" if="[[_right]]">
-      <table>
-        <tbody>
-          <template is="dom-repeat" items="[[_right]]">
-            <tr>
-              <td></td>
-              <td class="header">[[item.section]]</td>
-            </tr>
+          </thead>
+          <tbody>
             <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
               <tr>
                 <td>
@@ -95,10 +99,36 @@
                 <td>[[shortcut.text]]</td>
               </tr>
             </template>
-          </template>
-        </tbody>
-      </table>
-    </template>
+          </tbody>
+        </table>
+      </template>
+    </div>
+    <div class="column">
+      <template is="dom-repeat" items="[[_right]]">
+        <table>
+          <caption>
+            [[item.section]]
+          </caption>
+          <thead>
+            <tr>
+              <th>Key</th>
+              <th>Action</th>
+            </tr>
+          </thead>
+          <tbody>
+            <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
+              <tr>
+                <td>
+                  <gr-key-binding-display binding="[[shortcut.binding]]">
+                  </gr-key-binding-display>
+                </td>
+                <td>[[shortcut.text]]</td>
+              </tr>
+            </template>
+          </tbody>
+        </table>
+      </template>
+    </div>
   </main>
   <footer></footer>
 `;
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.js
deleted file mode 100644
index f76041e..0000000
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_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-keyboard-shortcuts-dialog.js';
-import {ShortcutSection} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-
-const basicFixture = fixtureFromElement('gr-keyboard-shortcuts-dialog');
-
-suite('gr-keyboard-shortcuts-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  function update(directory) {
-    element._onDirectoryUpdated(directory);
-    flush();
-  }
-
-  suite('_left and _right contents', () => {
-    test('empty dialog', () => {
-      assert.strictEqual(element._left.length, 0);
-      assert.strictEqual(element._right.length, 0);
-    });
-
-    test('everywhere goes on left', () => {
-      update(new Map([
-        [ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
-      ]));
-      assert.deepEqual(
-          element._left,
-          [
-            {
-              section: ShortcutSection.EVERYWHERE,
-              shortcuts: ['everywhere shortcuts'],
-            },
-          ]);
-      assert.strictEqual(element._right.length, 0);
-    });
-
-    test('navigation goes on left', () => {
-      update(new Map([
-        [ShortcutSection.NAVIGATION, ['navigation shortcuts']],
-      ]));
-      assert.deepEqual(
-          element._left,
-          [
-            {
-              section: ShortcutSection.NAVIGATION,
-              shortcuts: ['navigation shortcuts'],
-            },
-          ]);
-      assert.strictEqual(element._right.length, 0);
-    });
-
-    test('actions go on right', () => {
-      update(new Map([
-        [ShortcutSection.ACTIONS, ['actions shortcuts']],
-      ]));
-      assert.deepEqual(
-          element._right,
-          [
-            {
-              section: ShortcutSection.ACTIONS,
-              shortcuts: ['actions shortcuts'],
-            },
-          ]);
-      assert.strictEqual(element._left.length, 0);
-    });
-
-    test('reply dialog goes on right', () => {
-      update(new Map([
-        [ShortcutSection.REPLY_DIALOG, ['reply dialog shortcuts']],
-      ]));
-      assert.deepEqual(
-          element._right,
-          [
-            {
-              section: ShortcutSection.REPLY_DIALOG,
-              shortcuts: ['reply dialog shortcuts'],
-            },
-          ]);
-      assert.strictEqual(element._left.length, 0);
-    });
-
-    test('file list goes on right', () => {
-      update(new Map([
-        [ShortcutSection.FILE_LIST, ['file list shortcuts']],
-      ]));
-      assert.deepEqual(
-          element._right,
-          [
-            {
-              section: ShortcutSection.FILE_LIST,
-              shortcuts: ['file list shortcuts'],
-            },
-          ]);
-      assert.strictEqual(element._left.length, 0);
-    });
-
-    test('diffs go on right', () => {
-      update(new Map([
-        [ShortcutSection.DIFFS, ['diffs shortcuts']],
-      ]));
-      assert.deepEqual(
-          element._right,
-          [
-            {
-              section: ShortcutSection.DIFFS,
-              shortcuts: ['diffs shortcuts'],
-            },
-          ]);
-      assert.strictEqual(element._left.length, 0);
-    });
-
-    test('multiple sections on each side', () => {
-      update(new Map([
-        [ShortcutSection.ACTIONS, ['actions shortcuts']],
-        [ShortcutSection.DIFFS, ['diffs shortcuts']],
-        [ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
-        [ShortcutSection.NAVIGATION, ['navigation shortcuts']],
-      ]));
-      assert.deepEqual(
-          element._left,
-          [
-            {
-              section: ShortcutSection.EVERYWHERE,
-              shortcuts: ['everywhere shortcuts'],
-            },
-            {
-              section: ShortcutSection.NAVIGATION,
-              shortcuts: ['navigation shortcuts'],
-            },
-          ]);
-      assert.deepEqual(
-          element._right,
-          [
-            {
-              section: ShortcutSection.ACTIONS,
-              shortcuts: ['actions shortcuts'],
-            },
-            {
-              section: ShortcutSection.DIFFS,
-              shortcuts: ['diffs shortcuts'],
-            },
-          ]);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.ts
new file mode 100644
index 0000000..2c76704
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.ts
@@ -0,0 +1,156 @@
+/**
+ * @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 {GrKeyboardShortcutsDialog} from './gr-keyboard-shortcuts-dialog';
+import {
+  SectionView,
+  ShortcutSection,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+
+const basicFixture = fixtureFromElement('gr-keyboard-shortcuts-dialog');
+
+suite('gr-keyboard-shortcuts-dialog tests', () => {
+  let element: GrKeyboardShortcutsDialog;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  function update(directory: Map<ShortcutSection, SectionView>) {
+    element._onDirectoryUpdated(directory);
+    flush();
+  }
+
+  suite('_left and _right contents', () => {
+    test('empty dialog', () => {
+      assert.isEmpty(element._left);
+      assert.isEmpty(element._right);
+    });
+
+    test('everywhere goes on left', () => {
+      const sectionView = [{binding: [], text: 'everywhere shortcuts'}];
+      update(new Map([[ShortcutSection.EVERYWHERE, sectionView]]));
+      assert.deepEqual(element._left, [
+        {
+          section: ShortcutSection.EVERYWHERE,
+          shortcuts: sectionView,
+        },
+      ]);
+      assert.isEmpty(element._right);
+    });
+
+    test('navigation goes on left', () => {
+      const sectionView = [{binding: [], text: 'navigation shortcuts'}];
+      update(new Map([[ShortcutSection.NAVIGATION, sectionView]]));
+      assert.deepEqual(element._left, [
+        {
+          section: ShortcutSection.NAVIGATION,
+          shortcuts: sectionView,
+        },
+      ]);
+      assert.isEmpty(element._right);
+    });
+
+    test('actions go on right', () => {
+      const sectionView = [{binding: [], text: 'actions shortcuts'}];
+      update(new Map([[ShortcutSection.ACTIONS, sectionView]]));
+      assert.deepEqual(element._right, [
+        {
+          section: ShortcutSection.ACTIONS,
+          shortcuts: sectionView,
+        },
+      ]);
+      assert.isEmpty(element._left);
+    });
+
+    test('reply dialog goes on right', () => {
+      const sectionView = [{binding: [], text: 'reply dialog shortcuts'}];
+      update(new Map([[ShortcutSection.REPLY_DIALOG, sectionView]]));
+      assert.deepEqual(element._right, [
+        {
+          section: ShortcutSection.REPLY_DIALOG,
+          shortcuts: sectionView,
+        },
+      ]);
+      assert.isEmpty(element._left);
+    });
+
+    test('file list goes on right', () => {
+      const sectionView = [{binding: [], text: 'file list shortcuts'}];
+      update(new Map([[ShortcutSection.FILE_LIST, sectionView]]));
+      assert.deepEqual(element._right, [
+        {
+          section: ShortcutSection.FILE_LIST,
+          shortcuts: sectionView,
+        },
+      ]);
+      assert.isEmpty(element._left);
+    });
+
+    test('diffs go on right', () => {
+      const sectionView = [{binding: [], text: 'diffs shortcuts'}];
+      update(new Map([[ShortcutSection.DIFFS, sectionView]]));
+      assert.deepEqual(element._right, [
+        {
+          section: ShortcutSection.DIFFS,
+          shortcuts: sectionView,
+        },
+      ]);
+      assert.isEmpty(element._left);
+    });
+
+    test('multiple sections on each side', () => {
+      const actionsSectionView = [{binding: [], text: 'actions shortcuts'}];
+      const diffsSectionView = [{binding: [], text: 'diffs shortcuts'}];
+      const everywhereSectionView = [
+        {binding: [], text: 'everywhere shortcuts'},
+      ];
+      const navigationSectionView = [
+        {binding: [], text: 'navigation shortcuts'},
+      ];
+      update(
+        new Map([
+          [ShortcutSection.ACTIONS, actionsSectionView],
+          [ShortcutSection.DIFFS, diffsSectionView],
+          [ShortcutSection.EVERYWHERE, everywhereSectionView],
+          [ShortcutSection.NAVIGATION, navigationSectionView],
+        ])
+      );
+      assert.deepEqual(element._left, [
+        {
+          section: ShortcutSection.EVERYWHERE,
+          shortcuts: everywhereSectionView,
+        },
+        {
+          section: ShortcutSection.NAVIGATION,
+          shortcuts: navigationSectionView,
+        },
+      ]);
+      assert.deepEqual(element._right, [
+        {
+          section: ShortcutSection.ACTIONS,
+          shortcuts: actionsSectionView,
+        },
+        {
+          section: ShortcutSection.DIFFS,
+          shortcuts: diffsSectionView,
+        },
+      ]);
+    });
+  });
+});
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 ee103b0..816ed87 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -17,19 +17,14 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-account-dropdown/gr-account-dropdown';
 import '../gr-smart-search/gr-smart-search';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-main-header_html';
 import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {getAdminLinks, NavLink} from '../../../utils/admin-nav-util';
 import {customElement, property, observe} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   AccountDetailInfo,
   RequireProperties,
@@ -37,9 +32,9 @@
   TopMenuEntryInfo,
   TopMenuItemInfo,
 } from '../../../types/common';
-import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
 import {AuthType} from '../../../constants/constants';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
+import {appContext} from '../../../services/app-context';
 
 type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
 
@@ -103,17 +98,8 @@
   AuthType.CUSTOM_EXTENSION,
 ]);
 
-export interface GrMainHeader {
-  $: {
-    restAPI: RestApiService & Element;
-    jsAPI: JsApiService & Element;
-  };
-}
-
 @customElement('gr-main-header')
-export class GrMainHeader extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrMainHeader extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -158,9 +144,16 @@
   @property({type: String})
   _registerURL = '';
 
+  @property({type: String})
+  _feedbackURL = '';
+
   @property({type: Boolean})
   mobileSearchHidden = false;
 
+  private readonly restApiService = appContext.restApiService;
+
+  private readonly jsAPI = appContext.jsApiService;
+
   /** @override */
   ready() {
     super.ready();
@@ -168,15 +161,15 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadAccount();
     this._loadConfig();
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
+    super.disconnectedCallback();
   }
 
   reload() {
@@ -246,7 +239,7 @@
       } else if (items.length > 0) {
         links.push({
           title: m.name,
-          links: topMenuLinks[m.name] = items,
+          links: (topMenuLinks[m.name] = items),
         });
       }
     }
@@ -274,8 +267,8 @@
     this.loading = true;
 
     return Promise.all([
-      this.$.restAPI.getAccount(),
-      this.$.restAPI.getTopMenus(),
+      this.restApiService.getAccount(),
+      this.restApiService.getTopMenus(),
       getPluginLoader().awaitPluginsLoaded(),
     ]).then(result => {
       const account = result[0];
@@ -287,13 +280,13 @@
       return getAdminLinks(
         account,
         () =>
-          this.$.restAPI.getAccountCapabilities().then(capabilities => {
+          this.restApiService.getAccountCapabilities().then(capabilities => {
             if (!capabilities) {
               throw new Error('getAccountCapabilities returns undefined');
             }
             return capabilities;
           }),
-        () => this.$.jsAPI.getAdminMenuLinks()
+        () => this.jsAPI.getAdminMenuLinks()
       ).then(res => {
         this._adminLinks = res.links;
       });
@@ -301,14 +294,15 @@
   }
 
   _loadConfig() {
-    this.$.restAPI
+    this.restApiService
       .getConfig()
       .then(config => {
         if (!config) {
           throw new Error('getConfig returned undefined');
         }
+        this._retrieveFeedbackURL(config);
         this._retrieveRegisterURL(config);
-        return getDocsBaseUrl(config, this.$.restAPI);
+        return getDocsBaseUrl(config, this.restApiService);
       })
       .then(docBaseUrl => {
         this._docBaseUrl = docBaseUrl;
@@ -321,12 +315,18 @@
       return;
     }
 
-    this.$.restAPI.getPreferences().then(prefs => {
+    this.restApiService.getPreferences().then(prefs => {
       this._userLinks =
         prefs && prefs.my ? prefs.my.map(this._createHeaderLink) : [];
     });
   }
 
+  _retrieveFeedbackURL(config: ServerInfo) {
+    if (config.gerrit?.report_bug_url) {
+      this._feedbackURL = config.gerrit.report_bug_url;
+    }
+  }
+
   _retrieveRegisterURL(config: ServerInfo) {
     if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
       this._registerURL = config.auth.register_url ?? '';
@@ -348,6 +348,7 @@
     // If not, it sets target='_blank' on the menu item. The server
     // makes assumptions that work for the GWT UI, but not PolyGerrit,
     // so we'll just disable it altogether for now.
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
     const {target, ...headerLink} = {...linkObj};
 
     // Normalize all urls to PolyGerrit style.
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
index 5778fb8..7f3970f 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
@@ -79,6 +79,7 @@
       flex-grow: 1;
       margin: 0 var(--spacing-m);
       max-width: 500px;
+      min-width: 150px;
     }
     gr-dropdown,
     .browse {
@@ -92,6 +93,9 @@
     .settingsButton {
       margin-left: var(--spacing-m);
     }
+    .feedbackButton {
+      margin-left: var(--spacing-s);
+    }
     .browse {
       color: var(--header-text-color);
       /* Same as gr-button */
@@ -206,11 +210,22 @@
         class="hideOnMobile"
         name="header-browse-source"
       ></gr-endpoint-decorator>
+      <template is="dom-if" if="[[_feedbackURL]]">
+        <a class="feedbackButton"
+          href$="[[_feedbackURL]]"
+          title="File a bug"
+          aria-label="File a bug"
+          role="button"
+        >
+          <iron-icon icon="gr-icons:bug"></iron-icon>
+        </a>
+      </template>
+      </div>
       <div class="accountContainer" id="accountContainer">
         <iron-icon
           id="mobileSearch"
           icon="gr-icons:search"
-          on-tap="_onMobileSearchTap"
+          on-click="_onMobileSearchTap"
           role="button"
           aria-label="[[_computeShowHideAriaLabel(mobileSearchHidden)]]"
         ></iron-icon>
@@ -238,6 +253,4 @@
       </div>
     </div>
   </nav>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
index 3ab40e9..784b440 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
@@ -16,11 +16,12 @@
  */
 
 import '../../../test/common-test-setup-karma';
-import {isHidden, query} from '../../../test/test-utils';
+import {isHidden, query, stubRestApi} from '../../../test/test-utils';
 import './gr-main-header';
 import {GrMainHeader} from './gr-main-header';
 import {
   createAccountDetailWithId,
+  createGerritInfo,
   createServerInfo,
 } from '../../../test/test-data-generators';
 import {NavLink} from '../../../utils/admin-nav-util';
@@ -33,19 +34,8 @@
   let element: GrMainHeader;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() {
-        return Promise.resolve(createServerInfo());
-      },
-      probePath(_) {
-        return Promise.resolve(false);
-      },
-    });
-    stub('gr-main-header', {
-      _loadAccount() {
-        return Promise.resolve();
-      },
-    });
+    stubRestApi('probePath').returns(Promise.resolve(false));
+    stub('gr-main-header', '_loadAccount').callsFake(() => Promise.resolve());
     element = basicFixture.instantiate();
   });
 
@@ -482,6 +472,25 @@
     );
   });
 
+  test('shows feedback icon when URL provided', async () => {
+    assert.isEmpty(element._feedbackURL);
+    assert.isNotOk(query(element, '.feedbackButton'));
+
+    const url = 'report_bug_url';
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      gerrit: {
+        ...createGerritInfo(),
+        report_bug_url: url,
+      },
+    };
+    element._retrieveFeedbackURL(config);
+    await flush();
+
+    assert.equal(element._feedbackURL, url);
+    assert.ok(query(element, '.feedbackButton'));
+  });
+
   test('register URL', () => {
     assert.isTrue(isHidden(query(element, '.registerDiv')));
     const config: ServerInfo = {
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 8470611..fa81103 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -15,24 +15,26 @@
  * limitations under the License.
  */
 import {
+  BasePatchSetNum,
   BranchName,
+  ChangeConfigInfo,
   ChangeInfo,
+  CommentLinks,
+  CommitId,
+  DashboardId,
+  EditPatchSetNum,
+  GroupId,
+  Hashtag,
+  NumericChangeId,
+  ParentPatchSetNum,
   PatchSetNum,
   RepoName,
-  TopicName,
-  GroupId,
-  DashboardId,
-  NumericChangeId,
-  EditPatchSetNum,
-  ChangeConfigInfo,
-  CommitId,
-  Hashtag,
-  UrlEncodedCommentId,
-  CommentLinks,
-  ParentPatchSetNum,
   ServerInfo,
+  TopicName,
+  UrlEncodedCommentId,
 } from '../../../types/common';
-import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {GerritView} from '../../../services/router/router-model';
+import {ParsedChangeInfo} from '../../../types/types';
 
 // Navigation parameters object format:
 //
@@ -248,11 +250,11 @@
 
 export interface GenerateUrlChangeViewParameters {
   view: GerritView.CHANGE;
-  // TODO(TS): NumericChangeId - not sure about it, may be it can be removeds
+  // TODO(TS): NumericChangeId - not sure about it, may be it can be removed
   changeNum: NumericChangeId;
   project: RepoName;
   patchNum?: PatchSetNum;
-  basePatchNum?: PatchSetNum;
+  basePatchNum?: BasePatchSetNum;
   edit?: boolean;
   host?: string;
   messageHash?: string;
@@ -309,7 +311,7 @@
   project: RepoName;
   path?: string;
   patchNum?: PatchSetNum | null;
-  basePatchNum?: PatchSetNum | null;
+  basePatchNum?: BasePatchSetNum | null;
   lineNum?: number | string;
   leftSide?: boolean;
   commentId?: UrlEncodedCommentId;
@@ -396,22 +398,6 @@
   url?: string;
 }
 
-export enum GerritView {
-  ADMIN = 'admin',
-  AGREEMENTS = 'agreements',
-  CHANGE = 'change',
-  DASHBOARD = 'dashboard',
-  DIFF = 'diff',
-  DOCUMENTATION_SEARCH = 'documentation-search',
-  EDIT = 'edit',
-  GROUP = 'group',
-  PLUGIN_SCREEN = 'plugin-screen',
-  REPO = 'repo',
-  ROOT = 'root',
-  SEARCH = 'search',
-  SETTINGS = 'settings',
-}
-
 export enum GroupDetailView {
   MEMBERS = 'members',
   LOG = 'log',
@@ -450,7 +436,7 @@
 
   mapCommentlinks: uninitializedMapCommentLinks,
 
-  _checkPatchRange(patchNum?: PatchSetNum, basePatchNum?: PatchSetNum) {
+  _checkPatchRange(patchNum?: PatchSetNum, basePatchNum?: BasePatchSetNum) {
     if (basePatchNum && !patchNum) {
       throw new Error('Cannot use base patch number without patch number.');
     }
@@ -559,7 +545,6 @@
     return this._getUrlFor({
       view: GerritView.SEARCH,
       topic,
-      statuses: ['open', 'merged'],
       host,
     });
   },
@@ -607,7 +592,7 @@
   getUrlForChange(
     change: Pick<ChangeInfo, '_number' | 'project' | 'internalHost'>,
     patchNum?: PatchSetNum,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     isEdit?: boolean,
     messageHash?: string
   ) {
@@ -651,7 +636,7 @@
   navigateToChange(
     change: Pick<ChangeInfo, '_number' | 'project' | 'internalHost'>,
     patchNum?: PatchSetNum,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     isEdit?: boolean,
     redirect?: boolean
   ) {
@@ -668,7 +653,7 @@
     change: ChangeInfo | ParsedChangeInfo,
     filePath: string,
     patchNum?: PatchSetNum,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     lineNum?: number
   ) {
     return this.getUrlForDiffById(
@@ -702,7 +687,7 @@
     project: RepoName,
     filePath: string,
     patchNum?: PatchSetNum,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     lineNum?: number,
     leftSide?: boolean
   ) {
@@ -767,7 +752,7 @@
     change: ChangeInfo | ParsedChangeInfo,
     filePath: string,
     patchNum?: PatchSetNum,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     lineNum?: number
   ) {
     this._navigate(
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 21bf900..4aefc16 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -14,9 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {
   page,
@@ -24,9 +21,9 @@
   PageNextCallback,
 } from '../../../utils/page-wrapper-utils';
 import {htmlTemplate} from './gr-router_html';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {
   DashboardSection,
+  GeneratedWebLink,
   GenerateUrlChangeViewParameters,
   GenerateUrlDashboardViewParameters,
   GenerateUrlDiffViewParameters,
@@ -39,22 +36,18 @@
   GenerateWebLinksFileParameters,
   GenerateWebLinksParameters,
   GenerateWebLinksPatchsetParameters,
-  GerritView,
+  GerritNav,
+  GroupDetailView,
   isGenerateUrlDiffViewParameters,
   RepoDetailView,
   WeblinkType,
-  GroupDetailView,
-  GerritNav,
-  GeneratedWebLink,
 } from '../gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
-import {
-  patchNumEquals,
-  convertToPatchSetNum,
-} from '../../../utils/patch-set-util';
+import {convertToPatchSetNum} from '../../../utils/patch-set-util';
 import {customElement, property} from '@polymer/decorators';
 import {assertNever} from '../../../utils/common-util';
 import {
+  BasePatchSetNum,
   DashboardId,
   GroupId,
   NumericChangeId,
@@ -63,13 +56,24 @@
   ServerInfo,
   UrlEncodedCommentId,
 } from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   AppElement,
-  AppElementParams,
   AppElementAgreementParam,
+  AppElementParams,
 } from '../../gr-app-types';
 import {LocationChangeEventDetail} from '../../../types/events';
+import {GerritView, updateState} from '../../../services/router/router-model';
+import {firePageError} from '../../../utils/event-util';
+import {addQuotesWhen} from '../../../utils/string-util';
+import {windowLocationReload} from '../../../utils/dom-util';
+import {
+  encodeURL,
+  getBaseUrl,
+  toPath,
+  toPathname,
+  toSearchParams,
+} from '../../../utils/url-util';
+import {Execution, LifeCycle, Timing} from '../../../constants/reporting';
 
 const RoutePattern = {
   ROOT: '/',
@@ -263,16 +267,10 @@
 // Setup listeners outside of the router component initialization.
 (function () {
   window.addEventListener('WebComponentsReady', () => {
-    appContext.reportingService.timeEnd('WebComponentsReady');
+    appContext.reportingService.timeEnd(Timing.WEB_COMPONENTS_READY);
   });
 })();
 
-export interface GrRouter {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 export interface PageContextWithQueryMap extends PageContext {
   queryMap: Map<string, string> | URLSearchParams;
 }
@@ -290,13 +288,11 @@
 
 interface PatchRangeParams {
   patchNum?: PatchSetNum | null;
-  basePatchNum?: PatchSetNum | null;
+  basePatchNum?: BasePatchSetNum | null;
 }
 
 @customElement('gr-router')
-export class GrRouter extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRouter extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -314,9 +310,7 @@
 
   private readonly reporting = appContext.reportingService;
 
-  constructor() {
-    super();
-  }
+  private readonly restApiService = appContext.restApiService;
 
   start() {
     if (!this._app) {
@@ -326,6 +320,11 @@
   }
 
   _setParams(params: AppElementParams | GenerateUrlParameters) {
+    updateState(
+      params.view,
+      'changeNum' in params ? params.changeNum : undefined,
+      'patchNum' in params ? params.patchNum ?? undefined : undefined
+    );
     this._appElement().params = params;
   }
 
@@ -390,9 +389,8 @@
       case WeblinkType.PATCHSET:
         return this._getPatchSetWeblink(params);
       default:
-        console.warn(`Unsupported weblink ${(params as any).type}!`);
-        // TODO(TS): use assertNever(params.type)
-        return [];
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        assertNever(params, `Unsupported weblink ${(params as any).type}!`);
     }
   }
 
@@ -484,11 +482,18 @@
       operators.push('branch:' + encodeURL(params.branch, false));
     }
     if (params.topic) {
-      operators.push('topic:"' + encodeURL(params.topic, false) + '"');
+      operators.push(
+        'topic:' +
+          addQuotesWhen(encodeURL(params.topic, false), /\s/.test(params.topic))
+      );
     }
     if (params.hashtag) {
       operators.push(
-        'hashtag:"' + encodeURL(params.hashtag.toLowerCase(), false) + '"'
+        'hashtag:' +
+          addQuotesWhen(
+            encodeURL(params.hashtag.toLowerCase(), false),
+            /\s/.test(params.hashtag)
+          )
       );
     }
     if (params.statuses) {
@@ -658,7 +663,7 @@
       return Promise.resolve();
     }
 
-    return this.$.restAPI
+    return this.restApiService
       .getFromProjectLookup(params.changeNum)
       .then(project => {
         // Show a 404 and terminate if the lookup request failed. Attempting
@@ -690,10 +695,7 @@
 
     // Diffing a patch against itself is invalid, so if the base and revision
     // patches are equal clear the base.
-    if (
-      params.patchNum &&
-      patchNumEquals(params.basePatchNum, params.patchNum)
-    ) {
+    if (params.patchNum && params.basePatchNum === params.patchNum) {
       needsRedirect = true;
       params.basePatchNum = null;
     } else if (!hasPatchNum) {
@@ -745,7 +747,7 @@
    * (if it resolves).
    */
   _redirectIfNotLoggedIn(data: PageContext) {
-    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+    return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
         return Promise.resolve();
       } else {
@@ -757,24 +759,30 @@
 
   /**  Page.js middleware that warms the REST API's logged-in cache line. */
   _loadUserMiddleware(_: PageContext, next: PageNextCallback) {
-    this.$.restAPI.getLoggedIn().then(() => {
+    this.restApiService.getLoggedIn().then(() => {
       next();
     });
   }
 
   /**  Page.js middleware that try parse the querystring into queryMap. */
   _queryStringMiddleware(ctx: PageContext, next: PageNextCallback) {
-    let queryMap: Map<string, string> | URLSearchParams = new Map();
+    (ctx as PageContextWithQueryMap).queryMap = this.createQueryMap(ctx);
+    next();
+  }
+
+  private createQueryMap(ctx: PageContext) {
     if (ctx.querystring) {
       // https://caniuse.com/#search=URLSearchParams
       if (window.URLSearchParams) {
-        queryMap = new URLSearchParams(ctx.querystring);
+        return new URLSearchParams(ctx.querystring);
       } else {
-        queryMap = new Map(this._parseQueryString(ctx.querystring));
+        this.reporting.reportExecution(Execution.REACHABLE_CODE, {
+          id: 'noURLSearchParams',
+        });
+        return new Map(this._parseQueryString(ctx.querystring));
       }
     }
-    (ctx as PageContextWithQueryMap).queryMap = queryMap;
-    next();
+    return new Map<string, string>();
   }
 
   /**
@@ -796,20 +804,22 @@
     authRedirect?: boolean
   ) {
     if (!this[handlerName]) {
-      console.error('Attempted to map route to unknown method: ', handlerName);
+      this.reporting.error(
+        new Error(`Attempted to map route to unknown method: ${handlerName}`)
+      );
       return;
     }
     page(
       pattern,
       (ctx, next) => this._loadUserMiddleware(ctx, next),
       (ctx, next) => this._queryStringMiddleware(ctx, next),
-      data => {
+      ctx => {
         this.reporting.locationChanged(handlerName);
         const promise = authRedirect
-          ? this._redirectIfNotLoggedIn(data)
+          ? this._redirectIfNotLoggedIn(ctx)
           : Promise.resolve();
         promise.then(() => {
-          this[handlerName](data as PageContextWithQueryMap);
+          this[handlerName](ctx as PageContextWithQueryMap);
         });
       }
     );
@@ -843,6 +853,23 @@
       next();
     });
 
+    // Remove the tracking param 'usp' (User Source Parameter) from the URL,
+    // just to have users look at cleaner URLs.
+    page((ctx, next) => {
+      if (window.URLSearchParams) {
+        const pathname = toPathname(ctx.canonicalPath);
+        const searchParams = toSearchParams(ctx.canonicalPath);
+        if (searchParams.has('usp')) {
+          const usp = searchParams.get('usp');
+          this.reporting.reportLifeCycle(LifeCycle.USER_REFERRED_FROM, {usp});
+          searchParams.delete('usp');
+          this._redirect(toPath(pathname, searchParams));
+          return;
+        }
+      }
+      next();
+    });
+
     // Middleware
     page((ctx, next) => {
       document.body.scrollTop = 0;
@@ -857,7 +884,7 @@
 
       // Fire asynchronously so that the URL is changed by the time the event
       // is processed.
-      this.async(() => {
+      setTimeout(() => {
         const detail: LocationChangeEventDetail = {
           hash: window.location.hash,
           pathname: window.location.pathname,
@@ -1125,7 +1152,7 @@
       this._redirect(newUrl);
       return null;
     }
-    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+    return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
         this._redirect('/dashboard/self');
       } else {
@@ -1183,7 +1210,7 @@
     // User dashboard. We require viewing user to be logged in, else we
     // redirect to login for self dashboard or simple owner search for
     // other user dashboard.
-    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+    return this.restApiService.getLoggedIn().then(loggedIn => {
       if (!loggedIn) {
         if (data.params[0].toLowerCase() === 'self') {
           this._redirectToLogin(data.canonicalPath);
@@ -1538,38 +1565,42 @@
 
   _handleChangeRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
+    const changeNum = Number(ctx.params[1]) as NumericChangeId;
     const params: GenerateUrlChangeViewParameters = {
       project: ctx.params[0] as RepoName,
-      // TODO(TS): remove as unknown
-      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
-      basePatchNum: convertToPatchSetNum(ctx.params[4]),
+      changeNum,
+      basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
       patchNum: convertToPatchSetNum(ctx.params[6]),
       view: GerritView.CHANGE,
       queryMap: ctx.queryMap,
     };
 
     this.reporting.setRepoName(params.project);
+    this.reporting.setChangeId(changeNum);
     this._redirectOrNavigate(params);
   }
 
   _handleCommentRoute(ctx: PageContextWithQueryMap) {
+    const changeNum = Number(ctx.params[1]) as NumericChangeId;
     const params: GenerateUrlDiffViewParameters = {
       project: ctx.params[0] as RepoName,
-      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      changeNum,
       commentId: ctx.params[2] as UrlEncodedCommentId,
       view: GerritView.DIFF,
       commentLink: true,
     };
     this.reporting.setRepoName(params.project);
+    this.reporting.setChangeId(changeNum);
     this._redirectOrNavigate(params);
   }
 
   _handleDiffRoute(ctx: PageContextWithQueryMap) {
+    const changeNum = Number(ctx.params[1]) as NumericChangeId;
     // Parameter order is based on the regex group number matched.
     const params: GenerateUrlDiffViewParameters = {
       project: ctx.params[0] as RepoName,
-      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
-      basePatchNum: convertToPatchSetNum(ctx.params[4]),
+      changeNum,
+      basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
       patchNum: convertToPatchSetNum(ctx.params[6]),
       path: ctx.params[8],
       view: GerritView.DIFF,
@@ -1580,14 +1611,15 @@
       params.lineNum = address.lineNum;
     }
     this.reporting.setRepoName(params.project);
+    this.reporting.setChangeId(changeNum);
     this._redirectOrNavigate(params);
   }
 
   _handleChangeLegacyRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
     const params: GenerateUrlLegacyChangeViewParameters = {
-      changeNum: (ctx.params[0] as unknown) as NumericChangeId,
-      basePatchNum: convertToPatchSetNum(ctx.params[3]),
+      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,
@@ -1603,9 +1635,8 @@
   _handleDiffLegacyRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
     const params: GenerateUrlLegacyDiffViewParameters = {
-      // TODO(TS): remove "as unknown"
-      changeNum: (ctx.params[0] as unknown) as NumericChangeId,
-      basePatchNum: convertToPatchSetNum(ctx.params[2]),
+      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,
@@ -1623,9 +1654,10 @@
   _handleDiffEditRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
     const project = ctx.params[0] as RepoName;
+    const changeNum = Number(ctx.params[1]) as NumericChangeId;
     this._redirectOrNavigate({
       project,
-      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      changeNum,
       // for edit view params, patchNum cannot be undefined
       patchNum: convertToPatchSetNum(ctx.params[2])!,
       path: ctx.params[3],
@@ -1633,20 +1665,22 @@
       view: GerritView.EDIT,
     });
     this.reporting.setRepoName(project);
+    this.reporting.setChangeId(changeNum);
   }
 
   _handleChangeEditRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
     const project = ctx.params[0] as RepoName;
+    const changeNum = Number(ctx.params[1]) as NumericChangeId;
     this._redirectOrNavigate({
       project,
-      // TODO(TS): remove "as unknown"
-      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      changeNum,
       patchNum: convertToPatchSetNum(ctx.params[3]),
       view: GerritView.CHANGE,
       edit: true,
     });
     this.reporting.setRepoName(project);
+    this.reporting.setChangeId(changeNum);
   }
 
   /**
@@ -1707,7 +1741,7 @@
    * by the catchall _handleDefaultRoute handler.
    */
   _handlePassThroughRoute() {
-    location.reload();
+    windowLocationReload();
   }
 
   /**
@@ -1744,7 +1778,7 @@
 
   _handleDocumentationRedirectRoute(data: PageContextWithQueryMap) {
     if (data.params[1]) {
-      location.reload();
+      windowLocationReload();
     } else {
       // Redirect /Documentation to /Documentation/index.html
       this._redirect('/Documentation/index.html');
@@ -1768,9 +1802,7 @@
     // Note: the app's 404 display is tightly-coupled with catching 404
     // network responses, so we simulate a 404 response status to display it.
     // TODO: Decouple the gr-app error view from network responses.
-    this._appElement().dispatchEvent(
-      new CustomEvent('page-error', {detail: {response: {status: 404}}})
-    );
+    firePageError(new Response('', {status: 404}));
   }
 }
 
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_html.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_html.ts
index 91d8b41..1489006 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_html.ts
@@ -16,6 +16,4 @@
  */
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
+export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
index 927434b..ad4ebcb 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
@@ -19,7 +19,7 @@
 import './gr-router.js';
 import {page} from '../../../utils/page-wrapper-utils.js';
 import {GerritNav} from '../gr-navigation/gr-navigation.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
+import {stubBaseUrl, stubRestApi, addListenerForTest} from '../../../test/test-utils.js';
 import {_testOnly_RoutePattern} from './gr-router.js';
 
 const basicFixture = fixtureFromElement('gr-router');
@@ -229,7 +229,7 @@
   });
 
   test('_redirectIfNotLoggedIn while logged in', () => {
-    sinon.stub(element.$.restAPI, 'getLoggedIn')
+    stubRestApi('getLoggedIn')
         .returns(Promise.resolve(true));
     const data = {canonicalPath: ''};
     const redirectStub = sinon.stub(element, '_redirectToLogin');
@@ -239,7 +239,7 @@
   });
 
   test('_redirectIfNotLoggedIn while logged out', () => {
-    sinon.stub(element.$.restAPI, 'getLoggedIn')
+    stubRestApi('getLoggedIn')
         .returns(Promise.resolve(false));
     const redirectStub = sinon.stub(element, '_redirectToLogin');
     const data = {canonicalPath: ''};
@@ -267,12 +267,12 @@
       };
       assert.equal(element._generateUrl(params),
           '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
-          'topic:"g%2525h"+status:op%2525en');
+          'topic:g%2525h+status:op%2525en');
 
       params.offset = 100;
       assert.equal(element._generateUrl(params),
           '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
-          'topic:"g%2525h"+status:op%2525en,100');
+          'topic:g%2525h+status:op%2525en,100');
       delete params.offset;
 
       // The presence of the query param overrides other params.
@@ -288,6 +288,19 @@
       };
       assert.equal(element._generateUrl(params),
           '/q/(status:a OR status:b OR status:c)');
+
+      params = {
+        view: GerritNav.View.SEARCH,
+        topic: 'test',
+      };
+      assert.equal(element._generateUrl(params),
+          '/q/topic:test');
+      params = {
+        view: GerritNav.View.SEARCH,
+        topic: 'test test',
+      };
+      assert.equal(element._generateUrl(params),
+          '/q/topic:"test+test"');
     });
 
     test('change', () => {
@@ -523,8 +536,7 @@
     let generateUrlStub;
 
     setup(() => {
-      projectLookupStub = sinon
-          .stub(element.$.restAPI, 'getFromProjectLookup');
+      projectLookupStub = stubRestApi('getFromProjectLookup');
       generateUrlStub = sinon.stub(element, '_generateUrl');
     });
 
@@ -664,13 +676,11 @@
     });
 
     test('_handleDefaultRoute on first load', () => {
-      const appElementStub = {dispatchEvent: sinon.stub()};
-      element._appElement = () => appElementStub;
+      const spy = sinon.spy();
+      addListenerForTest(document, 'page-error', spy);
       element._handleDefaultRoute();
-      assert.isTrue(appElementStub.dispatchEvent.calledOnce);
-      assert.equal(
-          appElementStub.dispatchEvent.lastCall.args[0].detail.response.status,
-          404);
+      assert.isTrue(spy.calledOnce);
+      assert.equal(spy.lastCall.args[0].detail.response.status, 404);
     });
 
     test('_handleDefaultRoute after internal navigation', () => {
@@ -684,8 +694,6 @@
       sinon.stub(page, 'base');
       element._startRouter();
 
-      const appElementStub = {dispatchEvent: sinon.stub()};
-      element._appElement = () => appElementStub;
       element._handleDefaultRoute();
 
       onExit('', () => {}); // we left page;
@@ -794,8 +802,6 @@
       });
 
       test('redirects to dashboard if logged in', () => {
-        sinon.stub(element.$.restAPI, 'getLoggedIn')
-            .returns(Promise.resolve(true));
         const data = {
           canonicalPath: '/', path: '/', querystring: '', hash: '',
         };
@@ -807,8 +813,7 @@
       });
 
       test('redirects to open changes if not logged in', () => {
-        sinon.stub(element.$.restAPI, 'getLoggedIn')
-            .returns(Promise.resolve(false));
+        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
         const data = {
           canonicalPath: '/', path: '/', querystring: '', hash: '',
         };
@@ -905,8 +910,7 @@
       });
 
       test('own dashboard but signed out redirects to login', () => {
-        sinon.stub(element.$.restAPI, 'getLoggedIn')
-            .returns(Promise.resolve(false));
+        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
         const data = {canonicalPath: '/dashboard/', params: {0: 'seLF'}};
         return element._handleDashboardRoute(data, '').then(() => {
           assert.isTrue(redirectToLoginStub.calledOnce);
@@ -916,8 +920,7 @@
       });
 
       test('non-self dashboard but signed out does not redirect', () => {
-        sinon.stub(element.$.restAPI, 'getLoggedIn')
-            .returns(Promise.resolve(false));
+        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
         const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
         return element._handleDashboardRoute(data, '').then(() => {
           assert.isFalse(redirectToLoginStub.called);
@@ -928,8 +931,6 @@
       });
 
       test('dashboard while signed in sets params', () => {
-        sinon.stub(element.$.restAPI, 'getLoggedIn')
-            .returns(Promise.resolve(true));
         const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
         return element._handleDashboardRoute(data, '').then(() => {
           assert.isFalse(redirectToLoginStub.called);
@@ -1444,7 +1445,7 @@
         setup(() => {
           normalizeRangeStub = sinon.stub(element,
               '_normalizePatchRangeParams');
-          sinon.stub(element.$.restAPI, 'setInProjectLookup');
+          stubRestApi('setInProjectLookup');
         });
 
         test('needs redirect', () => {
@@ -1498,7 +1499,7 @@
         setup(() => {
           normalizeRangeStub = sinon.stub(element,
               '_normalizePatchRangeParams');
-          sinon.stub(element.$.restAPI, 'setInProjectLookup');
+          stubRestApi('setInProjectLookup');
         });
 
         test('needs redirect', () => {
@@ -1540,7 +1541,7 @@
           ]);
           assertDataToParams({params: groups.slice(1)}, '_handleCommentRoute', {
             project: 'gerrit',
-            changeNum: '264833',
+            changeNum: 264833,
             commentId: '00049681_f34fd6a9',
             commentLink: true,
             view: GerritNav.View.DIFF,
@@ -1551,7 +1552,7 @@
       test('_handleDiffEditRoute', () => {
         const normalizeRangeSpy =
             sinon.spy(element, '_normalizePatchRangeParams');
-        sinon.stub(element.$.restAPI, 'setInProjectLookup');
+        stubRestApi('setInProjectLookup');
         const ctx = {
           params: [
             'foo/bar', // 0 Project
@@ -1580,7 +1581,7 @@
       test('_handleDiffEditRoute with lineNum', () => {
         const normalizeRangeSpy =
             sinon.spy(element, '_normalizePatchRangeParams');
-        sinon.stub(element.$.restAPI, 'setInProjectLookup');
+        stubRestApi('setInProjectLookup');
         const ctx = {
           params: [
             'foo/bar', // 0 Project
@@ -1610,7 +1611,7 @@
       test('_handleChangeEditRoute', () => {
         const normalizeRangeSpy =
             sinon.spy(element, '_normalizePatchRangeParams');
-        sinon.stub(element.$.restAPI, 'setInProjectLookup');
+        stubRestApi('setInProjectLookup');
         const ctx = {
           params: [
             'foo/bar', // 0 Project
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index abbe316..44afaa1 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -15,11 +15,8 @@
  * limitations under the License.
  */
 import '../../shared/gr-autocomplete/gr-autocomplete';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-search-bar_html';
 import {
@@ -28,7 +25,6 @@
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {customElement, property} from '@polymer/decorators';
 import {ServerInfo} from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   AutocompleteQuery,
   AutocompleteSuggestion,
@@ -37,6 +33,7 @@
 import {getDocsBaseUrl} from '../../../utils/url-util';
 import {CustomKeyboardEvent} from '../../../types/events';
 import {MergeabilityComputationBehavior} from '../../../constants/constants';
+import {appContext} from '../../../services/app-context';
 
 // Possible static search options for auto complete, without negations.
 const SEARCH_OPERATORS: ReadonlyArray<string> = [
@@ -100,6 +97,7 @@
   'onlyextensions:',
   'owner:',
   'ownerin:',
+  'parentof:',
   'parentproject:',
   'project:',
   'projects:',
@@ -142,15 +140,12 @@
 
 export interface GrSearchBar {
   $: {
-    restAPI: RestApiService & Element;
     searchInput: GrAutocomplete;
   };
 }
 
 @customElement('gr-search-bar')
-export class GrSearchBar extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrSearchBar extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -193,14 +188,16 @@
   @property({type: String})
   docBaseUrl: string | null = null;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this.query = (input: string) => this._getSearchSuggestions(input);
   }
 
-  attached() {
-    super.attached();
-    this.$.restAPI.getConfig().then((serverConfig?: ServerInfo) => {
+  connectedCallback() {
+    super.connectedCallback();
+    this.restApiService.getConfig().then((serverConfig?: ServerInfo) => {
       const mergeability =
         serverConfig &&
         serverConfig.change &&
@@ -215,7 +212,7 @@
         this._addOperator('is:mergeable');
       }
       if (serverConfig) {
-        getDocsBaseUrl(serverConfig, this.$.restAPI).then(baseUrl => {
+        getDocsBaseUrl(serverConfig, this.restApiService).then(baseUrl => {
           this.docBaseUrl = baseUrl;
         });
       }
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
index 2fbdc7e..a0de7f2 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
@@ -47,6 +47,7 @@
         href$="[[_computeHelpDocLink(docBaseUrl)]]"
         target="_blank"
         class="help"
+        tabindex="-1"
       >
         <iron-icon
           icon="gr-icons:help-outline"
@@ -55,5 +56,4 @@
       </a>
     </gr-autocomplete>
   </form>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
index e470618..ae7e10c 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
@@ -18,7 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-search-bar.js';
 import '../../../scripts/util.js';
-import {TestKeyboardShortcutBinder} from '../../../test/test-utils.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';
 
@@ -123,6 +123,11 @@
   });
 
   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'}])
@@ -188,15 +193,11 @@
   ].forEach(mergeability => {
     suite(`mergeability as ${mergeability}`, () => {
       setup(done => {
-        stub('gr-rest-api-interface', {
-          getConfig() {
-            return Promise.resolve({
-              change: {
-                mergeability_computation_behavior: mergeability,
-              },
-            });
+        stubRestApi('getConfig').returns(Promise.resolve({
+          change: {
+            mergeability_computation_behavior: mergeability,
           },
-        });
+        }));
 
         element = basicFixture.instantiate();
         flush(done);
@@ -217,15 +218,11 @@
 
   suite('doc url', () => {
     setup(done => {
-      stub('gr-rest-api-interface', {
-        getConfig() {
-          return Promise.resolve({
-            gerrit: {
-              doc_url: 'https://doc.com/',
-            },
-          });
+      stubRestApi('getConfig').returns(Promise.resolve({
+        gerrit: {
+          doc_url: 'https://doc.com/',
         },
-      });
+      }));
 
       _testOnly_clearDocsBaseUrlCache();
       element = basicFixture.instantiate();
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
index 6b20c28..3dfe1eb 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -14,37 +14,26 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-search-bar/gr-search-bar';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-smart-search_html';
 import {GerritNav} from '../gr-navigation/gr-navigation';
 import {getUserName} from '../../../utils/display-name-util';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {AccountInfo, ServerInfo} from '../../../types/common';
 import {
   SearchBarHandleSearchDetail,
   SuggestionProvider,
 } from '../gr-search-bar/gr-search-bar';
 import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {appContext} from '../../../services/app-context';
 
 const MAX_AUTOCOMPLETE_RESULTS = 10;
 const SELF_EXPRESSION = 'self';
 const ME_EXPRESSION = 'me';
 
-export interface GrSmartSearch {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 @customElement('gr-smart-search')
-export class GrSmartSearch extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrSmartSearch extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -70,10 +59,12 @@
   @property({type: String})
   label = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
-    this.$.restAPI.getConfig().then(cfg => {
+  connectedCallback() {
+    super.connectedCallback();
+    this.restApiService.getConfig().then(cfg => {
       this._config = cfg;
     });
   }
@@ -97,7 +88,7 @@
     predicate: string,
     expression: string
   ): Promise<AutocompleteSuggestion[]> {
-    return this.$.restAPI
+    return this.restApiService
       .getSuggestedProjects(expression, MAX_AUTOCOMPLETE_RESULTS)
       .then(projects => {
         if (!projects) {
@@ -125,7 +116,7 @@
     if (expression.length === 0) {
       return Promise.resolve([]);
     }
-    return this.$.restAPI
+    return this.restApiService
       .getSuggestedGroups(expression, undefined, MAX_AUTOCOMPLETE_RESULTS)
       .then(groups => {
         if (!groups) {
@@ -153,7 +144,7 @@
     if (expression.length === 0) {
       return Promise.resolve([]);
     }
-    return this.$.restAPI
+    return this.restApiService
       .getSuggestedAccounts(expression, MAX_AUTOCOMPLETE_RESULTS)
       .then(accounts => {
         if (!accounts) {
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts
index 7088937..c08df15 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts
@@ -27,5 +27,4 @@
     group-suggestions="[[_groupSuggestions]]"
     account-suggestions="[[_accountSuggestions]]"
   ></gr-search-bar>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js
index dc7bf0e..f3a9965 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-smart-search.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-smart-search');
 
@@ -28,7 +29,7 @@
   });
 
   test('Autocompletes accounts', () => {
-    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake(() =>
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
       Promise.resolve([
         {
           name: 'fred',
@@ -42,7 +43,7 @@
   });
 
   test('Inserts self as option when valid', () => {
-    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake( () =>
+    stubRestApi('getSuggestedAccounts').callsFake( () =>
       Promise.resolve([
         {
           name: 'fred',
@@ -62,7 +63,7 @@
   });
 
   test('Inserts me as option when valid', () => {
-    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake( () =>
+    stubRestApi('getSuggestedAccounts').callsFake( () =>
       Promise.resolve([
         {
           name: 'fred',
@@ -82,7 +83,7 @@
   });
 
   test('Autocompletes groups', () => {
-    sinon.stub(element.$.restAPI, 'getSuggestedGroups').callsFake( () =>
+    stubRestApi('getSuggestedGroups').callsFake( () =>
       Promise.resolve({
         Polygerrit: 0,
         gerrit: 0,
@@ -95,7 +96,7 @@
   });
 
   test('Autocompletes projects', () => {
-    sinon.stub(element.$.restAPI, 'getSuggestedProjects').callsFake( () =>
+    stubRestApi('getSuggestedProjects').callsFake( () =>
       Promise.resolve({Polygerrit: 0}));
     return element._fetchProjects('project', 'pol').then(s => {
       assert.deepEqual(s[0], {text: 'project:Polygerrit'});
@@ -103,7 +104,7 @@
   });
 
   test('Autocomplete doesnt override exact matches to input', () => {
-    sinon.stub(element.$.restAPI, 'getSuggestedGroups').callsFake( () =>
+    stubRestApi('getSuggestedGroups').callsFake( () =>
       Promise.resolve({
         Polygerrit: 0,
         gerrit: 0,
@@ -118,7 +119,7 @@
   });
 
   test('Autocompletes accounts with no email', () => {
-    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake( () =>
+    stubRestApi('getSuggestedAccounts').callsFake( () =>
       Promise.resolve([{name: 'fred'}]));
     return element._fetchAccounts('owner', 'fr').then(s => {
       assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
@@ -126,7 +127,7 @@
   });
 
   test('Autocompletes accounts with email', () => {
-    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake( () =>
+    stubRestApi('getSuggestedAccounts').callsFake( () =>
       Promise.resolve([{email: 'fred@goog.co'}]));
     return element._fetchAccounts('owner', 'fr').then(s => {
       assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: ''});
diff --git a/polygerrit-ui/app/elements/custom-dark-theme_test.js b/polygerrit-ui/app/elements/custom-dark-theme_test.js
index ad12e14..97a2750 100644
--- a/polygerrit-ui/app/elements/custom-dark-theme_test.js
+++ b/polygerrit-ui/app/elements/custom-dark-theme_test.js
@@ -17,7 +17,6 @@
 
 import '../test/common-test-setup-karma.js';
 import {getComputedStyleValue} from '../utils/dom-util.js';
-import './shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import './gr-app.js';
 import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
 import {removeTheme} from '../styles/themes/dark-theme.js';
@@ -55,10 +54,10 @@
     assert.equal(
         getComputedStyleValue('--header-background-color', element)
             .toLowerCase(),
-        '#3b3d3f');
+        '#3c4043');
     assert.equal(
         getComputedStyleValue('--footer-background-color', element)
             .toLowerCase(),
-        '#3b3d3f');
+        '#3c4043');
   });
 });
diff --git a/polygerrit-ui/app/elements/custom-light-theme_test.js b/polygerrit-ui/app/elements/custom-light-theme_test.js
index 6d5b61e..35bc3a6 100644
--- a/polygerrit-ui/app/elements/custom-light-theme_test.js
+++ b/polygerrit-ui/app/elements/custom-light-theme_test.js
@@ -17,9 +17,9 @@
 
 import '../test/common-test-setup-karma.js';
 import {getComputedStyleValue} from '../utils/dom-util.js';
-import './shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import './gr-app.js';
 import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
+import {stubRestApi} from '../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-app');
 
@@ -27,14 +27,11 @@
   let element;
   setup(done => {
     window.localStorage.removeItem('dark-theme');
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({test: 'config'}); },
-      getAccount() { return Promise.resolve({}); },
-      getDiffComments() { return Promise.resolve({}); },
-      getDiffRobotComments() { return Promise.resolve({}); },
-      getDiffDrafts() { return Promise.resolve({}); },
-      _fetchSharedCacheURL() { return Promise.resolve({}); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({test: 'config'}));
+    stubRestApi('getAccount').returns(Promise.resolve({}));
+    stubRestApi('getDiffComments').returns(Promise.resolve({}));
+    stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+    stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
     element = basicFixture.instantiate();
     getPluginLoader().loadPlugins([]);
     getPluginLoader().awaitPluginsLoaded()
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 0e73516..0bf9f1c 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -18,34 +18,33 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-diff/gr-diff';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-apply-fix-dialog_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
-import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {
   NumericChangeId,
-  DiffInfo,
-  DiffPreferencesInfo,
   EditPatchSetNum,
   FixId,
   FixSuggestionInfo,
   PatchSetNum,
   RobotId,
+  BasePatchSetNum,
 } from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {isRobot} from '../../../utils/comment-util';
 import {OpenFixPreviewEvent} from '../../../types/events';
+import {appContext} from '../../../services/app-context';
+import {fireCloseFixPreview, fireEvent} from '../../../utils/event-util';
+import {ParsedChangeInfo} from '../../../types/types';
+import {GrButton} from '../../shared/gr-button/gr-button';
 
 export interface GrApplyFixDialog {
   $: {
-    restAPI: RestApiService & Element;
     applyFixOverlay: GrOverlay;
+    nextFix: GrButton;
   };
 }
 
@@ -55,9 +54,7 @@
 }
 
 @customElement('gr-apply-fix-dialog')
-export class GrApplyFixDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrApplyFixDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -102,6 +99,8 @@
 
   private refitOverlay?: () => void;
 
+  private readonly restApiService = appContext.restApiService;
+
   /**
    * Given robot comment CustomEvent object, fetch diffs associated
    * with first robot comment suggested fix and open dialog.
@@ -130,34 +129,24 @@
     );
     return Promise.all(promises).then(() => {
       // ensures gr-overlay repositions overlay in center
-      this.$.applyFixOverlay.dispatchEvent(
-        new CustomEvent('iron-resize', {
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireEvent(this.$.applyFixOverlay, 'iron-resize');
     });
   }
 
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.refitOverlay = () => {
       // re-center the dialog as content changed
-      this.$.applyFixOverlay.dispatchEvent(
-        new CustomEvent('iron-resize', {
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireEvent(this.$.applyFixOverlay, 'iron-resize');
     };
     this.addEventListener('diff-context-expanded', this.refitOverlay);
   }
 
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     if (this.refitOverlay) {
       this.removeEventListener('diff-context-expanded', this.refitOverlay);
     }
+    super.disconnectedCallback();
   }
 
   _showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
@@ -171,7 +160,7 @@
         new Error('Both _patchNum and changeNum must be set')
       );
     }
-    return this.$.restAPI
+    return this.restApiService
       .getRobotCommentFixPreview(this.changeNum, this._patchNum, fixId)
       .then(res => {
         if (res) {
@@ -181,7 +170,7 @@
         }
       })
       .catch(err => {
-        this._close();
+        this._close(false);
         throw err;
       });
   }
@@ -199,7 +188,7 @@
     if (e) {
       e.stopPropagation();
     }
-    this._close();
+    this._close(false);
   }
 
   addOneTo(_selectedFixIdx: number) {
@@ -238,17 +227,12 @@
     return _selectedFixIdx === fixSuggestions.length - 1;
   }
 
-  _close() {
+  _close(fixApplied: boolean) {
     this._currentFix = undefined;
     this._currentPreviews = [];
     this._isApplyFixLoading = false;
 
-    this.dispatchEvent(
-      new CustomEvent('close-fix-preview', {
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireCloseFixPreview(this, fixApplied);
     this.$.applyFixOverlay.close();
   }
 
@@ -291,12 +275,16 @@
       return Promise.reject(new Error('Not all required properties are set.'));
     }
     this._isApplyFixLoading = true;
-    return this.$.restAPI
+    return this.restApiService
       .applyFixSuggestion(changeNum, patchNum, this._currentFix.fix_id)
       .then(res => {
         if (res && res.ok) {
-          GerritNav.navigateToChange(change, EditPatchSetNum, patchNum);
-          this._close();
+          GerritNav.navigateToChange(
+            change,
+            EditPatchSetNum,
+            patchNum as BasePatchSetNum
+          );
+          this._close(true);
         }
         this._isApplyFixLoading = false;
       });
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
index 057fd01..52fa9841 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
@@ -95,5 +95,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.js b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.js
deleted file mode 100644
index cb73885..0000000
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.js
+++ /dev/null
@@ -1,298 +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-apply-fix-dialog.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-const basicFixture = fixtureFromElement('gr-apply-fix-dialog');
-
-suite('gr-apply-fix-dialog tests', () => {
-  let element;
-
-  const ROBOT_COMMENT_WITH_TWO_FIXES = {
-    robot_id: 'robot_1',
-    fix_suggestions: [{fix_id: 'fix_1'}, {fix_id: 'fix_2'}],
-  };
-
-  const ROBOT_COMMENT_WITH_ONE_FIX = {
-    robot_id: 'robot_1',
-    fix_suggestions: [{fix_id: 'fix_1'}],
-  };
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    element.changeNum = '1';
-    element._patchNum = 2;
-    element.change = {
-      _number: '1',
-      project: 'project',
-      revisions: {
-        abcd: {_number: 1},
-        efgh: {_number: 2},
-      },
-      current_revision: 'efgh',
-    };
-    element.prefs = {
-      font_size: 12,
-      line_length: 100,
-      tab_size: 4,
-    };
-  });
-
-  suite('dialog open', () => {
-    setup(() => {
-      sinon.stub(element.$.restAPI, 'getRobotCommentFixPreview')
-          .returns(Promise.resolve({
-            f1: {
-              meta_a: {},
-              meta_b: {},
-              content: [
-                {
-                  ab: ['loqlwkqll'],
-                },
-                {
-                  b: ['qwqqsqw'],
-                },
-                {
-                  ab: ['qwqqsqw', 'qweqeqweqeq', 'qweqweq'],
-                },
-              ],
-            },
-            f2: {
-              meta_a: {},
-              meta_b: {},
-              content: [
-                {
-                  ab: ['eqweqweqwex'],
-                },
-                {
-                  b: ['zassdasd'],
-                },
-                {
-                  ab: ['zassdasd', 'dasdasda', 'asdasdad'],
-                },
-              ],
-            },
-          }));
-      sinon.stub(element.$.applyFixOverlay, 'open')
-          .returns(Promise.resolve());
-    });
-
-    test('dialog opens fetch and sets previews', done => {
-      element.open({detail: {patchNum: 2,
-        comment: ROBOT_COMMENT_WITH_TWO_FIXES}})
-          .then(() => {
-            assert.equal(element._currentFix.fix_id, 'fix_1');
-            assert.equal(element._currentPreviews.length, 2);
-            assert.equal(element._robotId, 'robot_1');
-            const button = element.shadowRoot.querySelector(
-                '#applyFixDialog').shadowRoot.querySelector('#confirm');
-            assert.isFalse(button.hasAttribute('disabled'));
-            assert.equal(button.getAttribute('title'), '');
-            done();
-          });
-    });
-
-    test('tooltip is hidden if apply fix is loading', done => {
-      element.open({detail: {patchNum: 2,
-        comment: ROBOT_COMMENT_WITH_TWO_FIXES}})
-          .then(() => {
-            element._isApplyFixLoading = true;
-            const button = element.shadowRoot.querySelector(
-                '#applyFixDialog').shadowRoot.querySelector('#confirm');
-            assert.isTrue(button.hasAttribute('disabled'));
-            assert.equal(button.getAttribute('title'), '');
-            done();
-          });
-    });
-
-    test('apply fix button is disabled on older patchset', done => {
-      element.change = {
-        _number: '1',
-        project: 'project',
-        revisions: {
-          abcd: {_number: 1},
-          efgh: {_number: 2},
-        },
-        current_revision: 'abcd',
-      };
-      element.open({detail: {patchNum: 2,
-        comment: ROBOT_COMMENT_WITH_ONE_FIX}})
-          .then(() => {
-            flush(() => {
-              const button = element.shadowRoot.querySelector(
-                  '#applyFixDialog').shadowRoot.querySelector('#confirm');
-              assert.isTrue(button.hasAttribute('disabled'));
-              assert.equal(button.getAttribute('title'),
-                  'Fix can only be applied to the latest patchset');
-              done();
-            });
-          });
-    });
-  });
-
-  test('next button state updated when suggestions changed', done => {
-    sinon.stub(element.$.restAPI, 'getRobotCommentFixPreview')
-        .returns(Promise.resolve({}));
-    sinon.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
-
-    element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT_WITH_ONE_FIX}})
-        .then(() => assert.isTrue(element.$.nextFix.disabled))
-        .then(() =>
-          element.open({detail: {patchNum: 2,
-            comment: ROBOT_COMMENT_WITH_TWO_FIXES}}))
-        .then(() => {
-          assert.isFalse(element.$.nextFix.disabled);
-          done();
-        });
-  });
-
-  test('preview endpoint throws error should reset dialog', done => {
-    sinon.stub(window, 'fetch').callsFake((url => {
-      if (url.endsWith('/preview')) {
-        return Promise.reject(new Error('backend error'));
-      }
-      return Promise.resolve({
-        ok: true,
-        text() { return Promise.resolve(''); },
-        status: 200,
-      });
-    }));
-    const errorStub = sinon.stub();
-    document.addEventListener('network-error', errorStub);
-    element.open({detail: {patchNum: 2,
-      comment: ROBOT_COMMENT_WITH_TWO_FIXES}});
-    flush(() => {
-      assert.isTrue(errorStub.called);
-      assert.equal(element._currentFix, undefined);
-      done();
-    });
-  });
-
-  test('apply fix button should call apply ' +
-  'and navigate to change view', () => {
-    sinon.stub(element.$.restAPI, 'applyFixSuggestion')
-        .returns(Promise.resolve({ok: true}));
-    sinon.stub(GerritNav, 'navigateToChange');
-    element._currentFix = {fix_id: '123'};
-
-    return element._handleApplyFix().then(() => {
-      assert.isTrue(element.$.restAPI.applyFixSuggestion
-          .calledWithExactly('1', 2, '123'));
-      assert.isTrue(GerritNav.navigateToChange.calledWithExactly({
-        _number: '1',
-        project: 'project',
-        revisions: {
-          abcd: {_number: 1},
-          efgh: {_number: 2},
-        },
-        current_revision: 'efgh',
-      }, 'edit', 2));
-
-      // reset gr-apply-fix-dialog and close
-      assert.equal(element._currentFix, undefined);
-      assert.equal(element._currentPreviews.length, 0);
-    });
-  });
-
-  test('should not navigate to change view if incorect reponse', done => {
-    sinon.stub(element.$.restAPI, 'applyFixSuggestion')
-        .returns(Promise.resolve({}));
-    sinon.stub(GerritNav, 'navigateToChange');
-    element._currentFix = {fix_id: '123'};
-
-    element._handleApplyFix().then(() => {
-      assert.isTrue(element.$.restAPI.applyFixSuggestion
-          .calledWithExactly('1', 2, '123'));
-      assert.isTrue(GerritNav.navigateToChange.notCalled);
-
-      assert.equal(element._isApplyFixLoading, false);
-      done();
-    });
-  });
-
-  test('select fix forward and back of multiple suggested fixes', done => {
-    sinon.stub(element.$.restAPI, 'getRobotCommentFixPreview')
-        .returns(Promise.resolve({
-          f1: {
-            meta_a: {},
-            meta_b: {},
-            content: [
-              {
-                ab: ['loqlwkqll'],
-              },
-              {
-                b: ['qwqqsqw'],
-              },
-              {
-                ab: ['qwqqsqw', 'qweqeqweqeq', 'qweqweq'],
-              },
-            ],
-          },
-          f2: {
-            meta_a: {},
-            meta_b: {},
-            content: [
-              {
-                ab: ['eqweqweqwex'],
-              },
-              {
-                b: ['zassdasd'],
-              },
-              {
-                ab: ['zassdasd', 'dasdasda', 'asdasdad'],
-              },
-            ],
-          },
-        }));
-    sinon.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
-
-    element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT_WITH_TWO_FIXES}})
-        .then(() => {
-          element._onNextFixClick();
-          assert.equal(element._currentFix.fix_id, 'fix_2');
-          element._onPrevFixClick();
-          assert.equal(element._currentFix.fix_id, 'fix_1');
-          done();
-        });
-  });
-
-  test('server-error should throw for failed apply call', done => {
-    sinon.stub(window, 'fetch').callsFake((url => {
-      if (url.endsWith('/apply')) {
-        return Promise.reject(new Error('backend error'));
-      }
-      return Promise.resolve({
-        ok: true,
-        text() { return Promise.resolve(''); },
-        status: 200,
-      });
-    }));
-    const errorStub = sinon.stub();
-    document.addEventListener('network-error', errorStub);
-    sinon.stub(GerritNav, 'navigateToChange');
-    element._currentFix = {fix_id: '123'};
-    element._handleApplyFix();
-    flush(() => {
-      assert.isFalse(GerritNav.navigateToChange.called);
-      assert.isTrue(errorStub.called);
-      done();
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..d3d7615
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -0,0 +1,376 @@
+/**
+ * @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';
+import './gr-apply-fix-dialog';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {GrApplyFixDialog} from './gr-apply-fix-dialog';
+import {
+  BasePatchSetNum,
+  EditPatchSetNum,
+  PatchSetNum,
+  RobotId,
+  RobotRunId,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {
+  createFixSuggestionInfo,
+  createParsedChange,
+  createRevisions,
+  getCurrentRevision,
+} from '../../../test/test-data-generators';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {DiffInfo} from '../../../types/diff';
+import {UIRobot} from '../../../utils/comment-util';
+import {
+  CloseFixPreviewEventDetail,
+  EventType,
+  OpenFixPreviewEventDetail,
+} from '../../../types/events';
+import {GrButton} from '../../shared/gr-button/gr-button';
+
+const basicFixture = fixtureFromElement('gr-apply-fix-dialog');
+
+suite('gr-apply-fix-dialog tests', () => {
+  let element: GrApplyFixDialog;
+
+  const ROBOT_COMMENT_WITH_TWO_FIXES: UIRobot = {
+    id: '1' as UrlEncodedCommentId,
+    updated: '2018-02-08 18:49:18.000000000' 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 ROBOT_COMMENT_WITH_ONE_FIX: UIRobot = {
+    id: '2' as UrlEncodedCommentId,
+    updated: '2018-02-08 18:49:18.000000000' as Timestamp,
+    robot_id: 'robot_1' as RobotId,
+    robot_run_id: 'run_1' as RobotRunId,
+    properties: {},
+    fix_suggestions: [createFixSuggestionInfo('fix_1')],
+  };
+
+  function getConfirmButton(): GrButton {
+    return queryAndAssert(
+      queryAndAssert(element, '#applyFixDialog'),
+      '#confirm'
+    );
+  }
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    const change = {
+      ...createParsedChange(),
+      revisions: createRevisions(2),
+      current_revision: getCurrentRevision(1),
+    };
+    element.changeNum = change._number;
+    element._patchNum = change.revisions[change.current_revision]._number;
+    element.change = change;
+    element.prefs = {
+      ...createDefaultDiffPrefs(),
+      font_size: 12,
+      line_length: 100,
+      tab_size: 4,
+    };
+  });
+
+  suite('dialog open', () => {
+    setup(() => {
+      const diffInfo1: DiffInfo = {
+        meta_a: {
+          name: 'f1',
+          content_type: 'text',
+          lines: 10,
+        },
+        meta_b: {
+          name: 'f1',
+          content_type: 'text',
+          lines: 12,
+        },
+        content: [
+          {
+            ab: ['loqlwkqll'],
+          },
+          {
+            b: ['qwqqsqw'],
+          },
+          {
+            ab: ['qwqqsqw', 'qweqeqweqeq', 'qweqweq'],
+          },
+        ],
+        change_type: 'MODIFIED',
+        intraline_status: 'OK',
+      };
+
+      const diffInfo2: DiffInfo = {
+        meta_a: {
+          name: 'f2',
+          content_type: 'text',
+          lines: 10,
+        },
+        meta_b: {
+          name: 'f2',
+          content_type: 'text',
+          lines: 12,
+        },
+        content: [
+          {
+            ab: ['eqweqweqwex'],
+          },
+          {
+            b: ['zassdasd'],
+          },
+          {
+            ab: ['zassdasd', 'dasdasda', 'asdasdad'],
+          },
+        ],
+        change_type: 'MODIFIED',
+        intraline_status: 'OK',
+      };
+
+      stubRestApi('getRobotCommentFixPreview').returns(
+        Promise.resolve({
+          f1: diffInfo1,
+          f2: diffInfo2,
+        })
+      );
+      sinon.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+    });
+
+    test('dialog opens fetch and sets previews', async () => {
+      await element.open(
+        new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
+          detail: {
+            patchNum: 2 as PatchSetNum,
+            comment: ROBOT_COMMENT_WITH_TWO_FIXES,
+          },
+        })
+      );
+      assert.equal(element._currentFix!.fix_id, 'fix_1');
+      assert.equal(element._currentPreviews.length, 2);
+      assert.equal(element._robotId, 'robot_1' as RobotId);
+      const button = getConfirmButton();
+      assert.isFalse(button.hasAttribute('disabled'));
+      assert.equal(button.getAttribute('title'), '');
+    });
+
+    test('tooltip is hidden if apply fix is loading', async () => {
+      await element.open(
+        new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
+          detail: {
+            patchNum: 2 as PatchSetNum,
+            comment: ROBOT_COMMENT_WITH_TWO_FIXES,
+          },
+        })
+      );
+      element._isApplyFixLoading = true;
+      const button = getConfirmButton();
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.equal(button.getAttribute('title'), '');
+    });
+
+    test('apply fix button is disabled on older patchset', async () => {
+      element.change = element.change = {
+        ...createParsedChange(),
+        revisions: createRevisions(2),
+        current_revision: getCurrentRevision(0),
+      };
+      await element.open(
+        new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
+          detail: {
+            patchNum: 2 as PatchSetNum,
+            comment: ROBOT_COMMENT_WITH_ONE_FIX,
+          },
+        })
+      );
+      await flush();
+      const button = getConfirmButton();
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.equal(
+        button.getAttribute('title'),
+        'Fix can only be applied to the latest patchset'
+      );
+    });
+  });
+
+  test('next button state updated when suggestions changed', async () => {
+    stubRestApi('getRobotCommentFixPreview').returns(Promise.resolve({}));
+    sinon.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+
+    await element.open(
+      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
+        detail: {
+          patchNum: 2 as PatchSetNum,
+          comment: ROBOT_COMMENT_WITH_ONE_FIX,
+        },
+      })
+    );
+    assert.isTrue(element.$.nextFix.disabled);
+    await element.open(
+      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
+        detail: {
+          patchNum: 2 as PatchSetNum,
+          comment: ROBOT_COMMENT_WITH_TWO_FIXES,
+        },
+      })
+    );
+    assert.isFalse(element.$.nextFix.disabled);
+  });
+
+  test('preview endpoint throws error should reset dialog', async () => {
+    stubRestApi('getRobotCommentFixPreview').returns(
+      Promise.reject(new Error('backend error'))
+    );
+    element.open(
+      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
+        detail: {
+          patchNum: 2 as PatchSetNum,
+          comment: ROBOT_COMMENT_WITH_TWO_FIXES,
+        },
+      })
+    );
+    await flush();
+    assert.equal(element._currentFix, undefined);
+  });
+
+  test('apply fix button should call apply, navigate to change view and fire close', async () => {
+    const applyFixSuggestionStub = stubRestApi('applyFixSuggestion').returns(
+      Promise.resolve(new Response(null, {status: 200}))
+    );
+    const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+    element._currentFix = createFixSuggestionInfo('123');
+
+    const closeFixPreviewEventSpy = sinon.spy();
+    // Element is recreated after each test, removeEventListener isn't required
+    element.addEventListener(
+      EventType.CLOSE_FIX_PREVIEW,
+      closeFixPreviewEventSpy
+    );
+    await element._handleApplyFix(new CustomEvent('confirm'));
+
+    sinon.assert.calledOnceWithExactly(
+      applyFixSuggestionStub,
+      element.change!._number,
+      2 as PatchSetNum,
+      '123'
+    );
+    sinon.assert.calledWithExactly(
+      navigateToChangeStub,
+      element.change!,
+      EditPatchSetNum,
+      element.change!.revisions[2]._number as BasePatchSetNum
+    );
+
+    sinon.assert.calledOnceWithExactly(
+      closeFixPreviewEventSpy,
+      new CustomEvent<CloseFixPreviewEventDetail>(EventType.CLOSE_FIX_PREVIEW, {
+        detail: {
+          fixApplied: true,
+        },
+      })
+    );
+
+    // reset gr-apply-fix-dialog and close
+    assert.equal(element._currentFix, undefined);
+    assert.equal(element._currentPreviews.length, 0);
+  });
+
+  test('should not navigate to change view if incorect reponse', async () => {
+    const applyFixSuggestionStub = stubRestApi('applyFixSuggestion').returns(
+      Promise.resolve(new Response(null, {status: 500}))
+    );
+    const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+    element._currentFix = createFixSuggestionInfo('fix_123');
+
+    await element._handleApplyFix(new CustomEvent('confirm'));
+    sinon.assert.calledWithExactly(
+      applyFixSuggestionStub,
+      element.change!._number,
+      2 as PatchSetNum,
+      'fix_123'
+    );
+    assert.isTrue(navigateToChangeStub.notCalled);
+
+    assert.equal(element._isApplyFixLoading, false);
+  });
+
+  test('select fix forward and back of multiple suggested fixes', async () => {
+    sinon.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+
+    await element.open(
+      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
+        detail: {
+          patchNum: 2 as PatchSetNum,
+          comment: ROBOT_COMMENT_WITH_TWO_FIXES,
+        },
+      })
+    );
+    element._onNextFixClick(new CustomEvent('click'));
+    assert.equal(element._currentFix!.fix_id, 'fix_2');
+    element._onPrevFixClick(new CustomEvent('click'));
+    assert.equal(element._currentFix!.fix_id, 'fix_1');
+  });
+
+  test('server-error should throw for failed apply call', async () => {
+    stubRestApi('applyFixSuggestion').returns(
+      Promise.reject(new Error('backend error'))
+    );
+    const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+    element._currentFix = createFixSuggestionInfo('fix_123');
+
+    const closeFixPreviewEventSpy = sinon.spy();
+    // Element is recreated after each test, removeEventListener isn't required
+    element.addEventListener(
+      EventType.CLOSE_FIX_PREVIEW,
+      closeFixPreviewEventSpy
+    );
+
+    let expectedError;
+    await element._handleApplyFix(new CustomEvent('click')).catch(e => {
+      expectedError = e;
+    });
+    assert.isOk(expectedError);
+    assert.isFalse(navigateToChangeStub.called);
+    sinon.assert.notCalled(closeFixPreviewEventSpy);
+  });
+
+  test('onCancel fires close with correct parameters', () => {
+    const closeFixPreviewEventSpy = sinon.spy();
+    // Element is recreated after each test, removeEventListener isn't required
+    element.addEventListener(
+      EventType.CLOSE_FIX_PREVIEW,
+      closeFixPreviewEventSpy
+    );
+    element.onCancel(new CustomEvent('cancel'));
+    sinon.assert.calledOnceWithExactly(
+      closeFixPreviewEventSpy,
+      new CustomEvent<CloseFixPreviewEventDetail>(EventType.CLOSE_FIX_PREVIEW, {
+        detail: {
+          fixApplied: false,
+        },
+      })
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 2f6a1b4..3836b98 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -14,61 +14,48 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment-api_html';
-import {
-  getParentIndex,
-  isMergeParent,
-  patchNumEquals,
-} from '../../../utils/patch-set-util';
+import {CURRENT} from '../../../utils/patch-set-util';
 import {customElement, property} from '@polymer/decorators';
 import {
   CommentBasics,
-  ConfigInfo,
-  ParentPatchSetNum,
   PatchRange,
   PatchSetNum,
   PathToRobotCommentsInfoMap,
   RobotCommentInfo,
   UrlEncodedCommentId,
   NumericChangeId,
+  PathToCommentsInfoMap,
+  FileInfo,
+  ParentPatchSetNum,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
-import {CommentSide} from '../../../constants/constants';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   Comment,
   CommentMap,
   CommentThread,
   DraftInfo,
   isUnresolved,
-  sortComments,
   UIComment,
   UIDraft,
   UIHuman,
   UIRobot,
+  createCommentThreads,
+  isInPatchRange,
+  isDraftThread,
+  isInBaseOfPatchRange,
+  isInRevisionOfPatchRange,
+  isPatchsetLevel,
 } 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';
 
 export type CommentIdToCommentThreadMap = {
   [urlEncodedCommentId: string]: CommentThread;
 };
 
-export interface TwoSidesComments {
-  // TODO(TS): remove meta - it is not used anywhere
-  meta: {
-    changeNum: NumericChangeId;
-    path: string;
-    patchRange: PatchRange;
-    projectConfig?: ConfigInfo;
-  };
-  left: UIComment[];
-  right: UIComment[];
-}
-
 export class ChangeComments {
   private readonly _comments: {[path: string]: UIHuman[]};
 
@@ -76,7 +63,9 @@
 
   private readonly _drafts: {[path: string]: UIDraft[]};
 
-  private readonly _changeNum: NumericChangeId;
+  private readonly _portedComments: PathToCommentsInfoMap;
+
+  private readonly _portedDrafts: PathToCommentsInfoMap;
 
   /**
    * Construct a change comments object, which can be data-bound to child
@@ -86,13 +75,14 @@
     comments: {[path: string]: UIHuman[]} | undefined,
     robotComments: {[path: string]: UIRobot[]} | undefined,
     drafts: {[path: string]: UIDraft[]} | undefined,
-    changeNum: NumericChangeId
+    portedComments: PathToCommentsInfoMap | undefined,
+    portedDrafts: PathToCommentsInfoMap | undefined
   ) {
     this._comments = this._addPath(comments);
     this._robotComments = this._addPath(robotComments);
     this._drafts = this._addPath(drafts);
-    // TODO(TS): remove changeNum param - it is not used anywhere
-    this._changeNum = changeNum;
+    this._portedComments = portedComments || {};
+    this._portedDrafts = portedDrafts || {};
   }
 
   /**
@@ -117,18 +107,10 @@
     return updatedComments;
   }
 
-  get comments() {
-    return this._comments;
-  }
-
   get drafts() {
     return this._drafts;
   }
 
-  get robotComments() {
-    return this._robotComments;
-  }
-
   findCommentById(commentId?: UrlEncodedCommentId): UIComment | undefined {
     if (!commentId) return undefined;
     const findComment = (comments: {[path: string]: UIComment[]}) => {
@@ -158,23 +140,15 @@
    */
   getPaths(patchRange?: PatchRange): CommentMap {
     const responses: {[path: string]: UIComment[]}[] = [
-      this.comments,
+      this._comments,
       this.drafts,
-      this.robotComments,
+      this._robotComments,
     ];
     const commentMap: CommentMap = {};
     for (const response of responses) {
-      for (const path in response) {
-        if (
-          hasOwnProperty(response, path) &&
-          response[path].some(c => {
-            // If don't care about patch range, we know that the path exists.
-            if (!patchRange) {
-              return true;
-            }
-            return this._isInPatchRange(c, patchRange);
-          })
-        ) {
+      for (const [path, comments] of Object.entries(response)) {
+        // If don't care about patch range, we know that the path exists.
+        if (comments.some(c => !patchRange || isInPatchRange(c, patchRange))) {
           commentMap[path] = true;
         }
       }
@@ -251,9 +225,7 @@
       allComments = allComments.concat(drafts);
     }
     if (patchNum) {
-      allComments = allComments.filter(c =>
-        patchNumEquals(c.patch_set, patchNum)
-      );
+      allComments = allComments.filter(c => c.patch_set === patchNum);
     }
     return allComments.map(c => {
       return {...c};
@@ -281,6 +253,29 @@
     return allComments;
   }
 
+  cloneWithUpdatedDrafts(drafts: {[path: string]: UIDraft[]} | undefined) {
+    return new ChangeComments(
+      this._comments,
+      this._robotComments,
+      drafts,
+      this._portedComments,
+      this._portedDrafts
+    );
+  }
+
+  cloneWithUpdatedPortedComments(
+    portedComments?: PathToCommentsInfoMap,
+    portedDrafts?: PathToCommentsInfoMap
+  ) {
+    return new ChangeComments(
+      this._comments,
+      this._robotComments,
+      this._drafts,
+      portedComments,
+      portedDrafts
+    );
+  }
+
   /**
    * Get the drafts for a path and optional patch num.
    *
@@ -290,7 +285,7 @@
   getAllDraftsForPath(path: string, patchNum?: PatchSetNum): Comment[] {
     let comments = this._drafts[path] || [];
     if (patchNum) {
-      comments = comments.filter(c => patchNumEquals(c.patch_set, patchNum));
+      comments = comments.filter(c => c.patch_set === patchNum);
     }
     return comments.map(c => {
       return {...c, __draft: true};
@@ -322,52 +317,142 @@
    * @param projectConfig Optional project config object to
    * include in the meta sub-object.
    */
-  getCommentsBySideForPath(
-    path: string,
-    patchRange: PatchRange,
-    projectConfig?: ConfigInfo
-  ): TwoSidesComments {
+  getCommentsForPath(path: string, patchRange: PatchRange): Comment[] {
     let comments: Comment[] = [];
     let drafts: DraftInfo[] = [];
     let robotComments: RobotCommentInfo[] = [];
-    if (this.comments && this.comments[path]) {
-      comments = this.comments[path];
+    if (this._comments && this._comments[path]) {
+      comments = this._comments[path];
     }
     if (this.drafts && this.drafts[path]) {
       drafts = this.drafts[path];
     }
-    if (this.robotComments && this.robotComments[path]) {
-      robotComments = this.robotComments[path];
+    if (this._robotComments && this._robotComments[path]) {
+      robotComments = this._robotComments[path];
     }
 
     drafts.forEach(d => {
       d.__draft = true;
     });
 
-    const all: Comment[] = comments
+    return comments
       .concat(drafts)
       .concat(robotComments)
+      .filter(c => isInPatchRange(c, patchRange))
       .map(c => {
         return {...c};
       });
+  }
 
-    const baseComments = all.filter(c =>
-      this._isInBaseOfPatchRange(c, patchRange)
-    );
-    const revisionComments = all.filter(c =>
-      this._isInRevisionOfPatchRange(c, patchRange)
-    );
+  /**
+   * Get the ported threads for given patch range.
+   * Ported threads are comment threads that were posted on an older patchset
+   * and are displayed on a later patchset.
+   * It is simply the original thread displayed on a newer patchset.
+   *
+   * Threads are ported over to all subsequent patchsets. So, a thread created
+   * on patchset 5 say will be ported over to patchsets 6,7,8 and beyond.
+   *
+   * Ported threads add a boolean property ported true to the thread object
+   * to indicate to the user that this is a ported thread.
+   *
+   * Any interactions with ported threads are reflected on the original threads.
+   * Replying to a ported thread ported from Patchset 6 shown on Patchset 10
+   * say creates a draft reply associated with Patchset 6, since the user is
+   * interacting with the original thread.
+   *
+   * Only threads with unresolved comments or drafts are ported over.
+   * If the thread is associated with either the left patchset or the right
+   * patchset, then we filter that ported thread from the return value
+   * as it will be rendered by default.
+   *
+   * If there is no appropriate range for the ported comments, then the backend
+   * does not return the range of the ported thread and it becomes a file level
+   * thread.
+   *
+   * If a comment was created with Side=PARENT, then we only show this ported
+   * comment if Base is part of the patch range, always on the left side of
+   * the diff.
+   *
+   * @return only the ported threads for the specified file and patch range
+   */
+  _getPortedCommentThreads(
+    file: PatchSetFile,
+    patchRange: PatchRange
+  ): CommentThread[] {
+    const portedComments = this._portedComments[file.path] || [];
+    portedComments.push(...(this._portedDrafts[file.path] || []));
+    if (file.basePath) {
+      portedComments.push(...(this._portedComments[file.basePath] || []));
+      portedComments.push(...(this._portedDrafts[file.basePath] || []));
+    }
+    if (!portedComments.length) return [];
 
-    return {
-      meta: {
-        changeNum: this._changeNum,
-        path,
-        patchRange,
-        projectConfig,
-      },
-      left: baseComments,
-      right: revisionComments,
-    };
+    // when forming threads in diff view, we filter for current patchrange but
+    // ported comments will involve comments that may not belong to the
+    // current patchrange, so we need to form threads for them using all
+    // comments
+    const allComments: UIComment[] = this.getAllCommentsForFile(file, true);
+
+    return createCommentThreads(allComments).filter(thread => {
+      // Robot comments and drafts are not ported over. A human reply to
+      // the robot comment will be ported over, thefore it's possible to
+      // have the root comment of the thread not be ported, hence loop over
+      // entire thread
+      const portedComment = portedComments.find(portedComment =>
+        thread.comments.some(c => portedComment.id === c.id)
+      );
+      if (!portedComment) return false;
+
+      const originalComment = thread.comments.find(
+        comment => comment.id === portedComment.id
+      )!;
+
+      if (
+        (originalComment.line && !portedComment.line) ||
+        (originalComment.range && !portedComment.range)
+      ) {
+        thread.rangeInfoLost = true;
+      }
+
+      if (
+        isInBaseOfPatchRange(thread.comments[0], patchRange) ||
+        isInRevisionOfPatchRange(thread.comments[0], patchRange)
+      ) {
+        // no need to port this thread as it will be rendered by default
+        return false;
+      }
+
+      thread.diffSide = Side.RIGHT;
+      if (thread.commentSide === CommentSide.PARENT) {
+        // TODO(dhruvsri): Add handling for merge parents
+        if (
+          patchRange.basePatchNum !== ParentPatchSetNum ||
+          !!thread.mergeParentNum
+        )
+          return false;
+        thread.diffSide = Side.LEFT;
+      }
+
+      if (!isUnresolved(thread) && !isDraftThread(thread)) return false;
+
+      thread.range = portedComment.range;
+      thread.line = portedComment.line;
+      thread.ported = true;
+      return true;
+    });
+  }
+
+  getThreadsBySideForFile(
+    file: PatchSetFile,
+    patchRange: PatchRange
+  ): CommentThread[] {
+    const threads = createCommentThreads(
+      this.getCommentsForFile(file, patchRange),
+      patchRange
+    );
+    threads.push(...this._getPortedCommentThreads(file, patchRange));
+    return threads;
   }
 
   /**
@@ -382,62 +467,28 @@
    * @param projectConfig Optional project config object to
    * include in the meta sub-object.
    */
-  getCommentsBySideForFile(
-    file: PatchSetFile,
-    patchRange: PatchRange,
-    projectConfig?: ConfigInfo
-  ): TwoSidesComments {
-    const comments = this.getCommentsBySideForPath(
-      file.path,
-      patchRange,
-      projectConfig
-    );
+  getCommentsForFile(file: PatchSetFile, patchRange: PatchRange): Comment[] {
+    const comments = this.getCommentsForPath(file.path, patchRange);
     if (file.basePath) {
-      const commentsForBasePath = this.getCommentsBySideForPath(
-        file.basePath,
-        patchRange,
-        projectConfig
-      );
-      // merge in the left and right
-      comments.left = comments.left.concat(commentsForBasePath.left);
-      comments.right = comments.right.concat(commentsForBasePath.right);
+      comments.push(...this.getCommentsForPath(file.basePath, patchRange));
     }
     return comments;
   }
 
-  /**
-   * @param comments Object keyed by file, with a value of an array
-   * of comments left on that file.
-   * @return A flattened list of all comments, where each comment
-   * also includes the file that it was left on, which was the key of the
-   * originall object.
-   */
-  _commentObjToArrayWithFile<T>(comments: {
-    [path: string]: T[];
-  }): Array<T & {__path: string}> {
-    let commentArr: Array<T & {__path: string}> = [];
-    for (const file of Object.keys(comments)) {
-      const commentsForFile: Array<T & {__path: string}> = [];
-      for (const comment of comments[file]) {
-        commentsForFile.push({...comment, __path: file});
-      }
-      commentArr = commentArr.concat(commentsForFile);
-    }
-    return commentArr;
-  }
-
   _commentObjToArray<T>(comments: {[path: string]: T[]}): T[] {
-    let commentArr: T[] = [];
-    for (const file of Object.keys(comments)) {
-      commentArr = commentArr.concat(comments[file]);
-    }
-    return commentArr;
+    return Object.keys(comments).reduce((commentArr: T[], file) => {
+      comments[file].forEach(c => commentArr.push({...c}));
+      return commentArr;
+    }, []);
   }
 
   /**
    * Computes the number of comment threads in a given file or patch.
    */
-  computeCommentThreadCount(file: PatchSetFile | PatchNumOnly) {
+  computeCommentThreadCount(
+    file: PatchSetFile | PatchNumOnly,
+    ignorePatchsetLevelComments?: boolean
+  ) {
     let comments: Comment[] = [];
     if (isPatchSetFile(file)) {
       comments = this.getAllCommentsForFile(file);
@@ -446,8 +497,10 @@
         this.getAllPublishedComments(file.patchNum)
       );
     }
-
-    return this.getCommentThreads(comments).length;
+    let threads = createCommentThreads(comments);
+    if (ignorePatchsetLevelComments)
+      threads = threads.filter(thread => !isPatchsetLevel(thread));
+    return threads.length;
   }
 
   /**
@@ -462,10 +515,59 @@
     return this._commentObjToArray(allDrafts).length;
   }
 
+  // TODO(dhruvsri): merge with computeDraftCount
+  computePortedDraftCount(patchRange: PatchRange, path: string) {
+    const threads = this.getThreadsBySideForFile({path}, patchRange);
+    return threads.filter(thread => isDraftThread(thread) && thread.ported)
+      .length;
+  }
+
+  /**
+   * @param includeUnmodified Included unmodified status of the file in the
+   * comment string or not. For files we opt of chip instead of a string.
+   * @param filterPatchset Only count threads which belong to this patchset
+   */
+  computeCommentsString(
+    patchRange?: PatchRange,
+    path?: string,
+    changeFileInfo?: FileInfo,
+    includeUnmodified?: boolean
+  ) {
+    if (!path) return '';
+    if (!patchRange) return '';
+
+    const threads = this.getThreadsBySideForFile({path}, patchRange);
+    const commentThreadCount = threads.filter(thread => !isDraftThread(thread))
+      .length;
+    const unresolvedCount = threads.reduce((cnt, thread) => {
+      if (isUnresolved(thread)) cnt += 1;
+      return cnt;
+    }, 0);
+
+    const commentThreadString = pluralize(commentThreadCount, 'comment');
+    const unresolvedString =
+      unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
+
+    const unmodifiedString =
+      includeUnmodified && changeFileInfo?.status === 'U' ? 'no changes' : '';
+
+    return (
+      commentThreadString +
+      // Add a space if both comments and unresolved
+      (commentThreadString && unresolvedString ? ' ' : '') +
+      // Add parentheses around unresolved if it exists.
+      (unresolvedString ? `(${unresolvedString})` : '') +
+      (unmodifiedString ? `(${unmodifiedString})` : '')
+    );
+  }
+
   /**
    * Computes a number of unresolved comment threads in a given file and path.
    */
-  computeUnresolvedNum(file: PatchSetFile | PatchNumOnly) {
+  computeUnresolvedNum(
+    file: PatchSetFile | PatchNumOnly,
+    ignorePatchsetLevelComments?: boolean
+  ) {
     let comments: Comment[] = [];
     let drafts: Comment[] = [];
 
@@ -479,108 +581,18 @@
     }
 
     comments = comments.concat(drafts);
-    const threads = this.getCommentThreads(sortComments(comments));
-    const unresolvedThreads = threads.filter(isUnresolved);
+    const threads = createCommentThreads(comments);
+    let unresolvedThreads = threads.filter(isUnresolved);
+    if (ignorePatchsetLevelComments)
+      unresolvedThreads = unresolvedThreads.filter(
+        thread => !isPatchsetLevel(thread)
+      );
     return unresolvedThreads.length;
   }
 
   getAllThreadsForChange() {
-    const comments = this._commentObjToArrayWithFile(this.getAllComments(true));
-    const sortedComments = sortComments(comments);
-    return this.getCommentThreads(sortedComments);
-  }
-
-  /**
-   * Computes all of the comments in thread format.
-   *
-   * @param comments sorted by updated timestamp.
-   */
-  getCommentThreads(comments: UIComment[]) {
-    const threads: CommentThread[] = [];
-    const idThreadMap: CommentIdToCommentThreadMap = {};
-    for (const comment of comments) {
-      if (!comment.id) continue;
-      // If the comment is in reply to another comment, find that comment's
-      // thread and append to it.
-      if (comment.in_reply_to) {
-        const thread = idThreadMap[comment.in_reply_to];
-        if (thread) {
-          thread.comments.push(comment);
-          idThreadMap[comment.id] = thread;
-          continue;
-        }
-      }
-
-      // Otherwise, this comment starts its own thread.
-      if (!comment.__path && !comment.path) {
-        throw new Error('Comment missing required "path".');
-      }
-      const newThread: CommentThread = {
-        comments: [comment],
-        patchNum: comment.patch_set,
-        path: comment.__path || comment.path!,
-        line: comment.line,
-        rootId: comment.id,
-      };
-      if (comment.side) {
-        newThread.commentSide = comment.side;
-      }
-      threads.push(newThread);
-      idThreadMap[comment.id] = newThread;
-    }
-    return threads;
-  }
-
-  /**
-   * Whether the given comment should be included in the base side of the
-   * given patch range.
-   */
-  _isInBaseOfPatchRange(comment: CommentBasics, range: PatchRange) {
-    // If the base of the patch range is a parent of a merge, and the comment
-    // appears on a specific parent then only show the comment if the parent
-    // index of the comment matches that of the range.
-    if (comment.parent && comment.side === CommentSide.PARENT) {
-      return (
-        isMergeParent(range.basePatchNum) &&
-        comment.parent === getParentIndex(range.basePatchNum)
-      );
-    }
-
-    // If the base of the range is the parent of the patch:
-    if (
-      range.basePatchNum === ParentPatchSetNum &&
-      comment.side === CommentSide.PARENT &&
-      patchNumEquals(comment.patch_set, range.patchNum)
-    ) {
-      return true;
-    }
-    // If the base of the range is not the parent of the patch:
-    return (
-      range.basePatchNum !== ParentPatchSetNum &&
-      comment.side !== CommentSide.PARENT &&
-      patchNumEquals(comment.patch_set, range.basePatchNum)
-    );
-  }
-
-  /**
-   * Whether the given comment should be included in the revision side of the
-   * given patch range.
-   */
-  _isInRevisionOfPatchRange(comment: CommentBasics, range: PatchRange) {
-    return (
-      comment.side !== CommentSide.PARENT &&
-      patchNumEquals(comment.patch_set, range.patchNum)
-    );
-  }
-
-  /**
-   * Whether the given comment should be included in the given patch range.
-   */
-  _isInPatchRange(comment: CommentBasics, range: PatchRange): boolean {
-    return (
-      this._isInBaseOfPatchRange(comment, range) ||
-      this._isInRevisionOfPatchRange(comment, range)
-    );
+    const comments = this._commentObjToArray(this.getAllComments(true));
+    return createCommentThreads(comments);
   }
 }
 
@@ -588,16 +600,10 @@
 export const _testOnly_findCommentById =
   ChangeComments.prototype.findCommentById;
 
-export interface GrCommentApi {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
+export const _testOnly_getCommentsForPath =
+  ChangeComments.prototype.getCommentsForPath;
 @customElement('gr-comment-api')
-export class GrCommentApi extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCommentApi extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -605,40 +611,36 @@
   @property({type: Object})
   _changeComments?: ChangeComments;
 
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('reload-drafts', changeNum =>
-      // TODO(TS): This is a wrong code, however keep it as is for now
-      // If changeNum param in ChangeComments is removed, this also must be
-      // removed
-      this.reloadDrafts((changeNum as unknown) as NumericChangeId)
-    );
-  }
+  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) {
-    const promises = [];
-    promises.push(this.$.restAPI.getDiffComments(changeNum));
-    promises.push(this.$.restAPI.getDiffRobotComments(changeNum));
-    promises.push(this.$.restAPI.getDiffDrafts(changeNum));
+  loadAll(changeNum: NumericChangeId, patchNum?: PatchSetNum) {
+    const revision = patchNum || CURRENT;
+    const commentsPromise = [
+      this.restApiService.getDiffComments(changeNum),
+      this.restApiService.getDiffRobotComments(changeNum),
+      this.restApiService.getDiffDrafts(changeNum),
+      this.restApiService.getPortedComments(changeNum, revision),
+      this.restApiService.getPortedDrafts(changeNum, revision),
+    ];
 
-    return Promise.all(promises).then(([comments, robotComments, drafts]) => {
-      this._changeComments = new ChangeComments(
-        comments,
-        // TODO(TS): Promise.all somehow resolve all types to
-        // PathToCommentsInfoMap given its PathToRobotCommentsInfoMap
-        // returned from the second promise
-        robotComments as PathToRobotCommentsInfoMap,
-        drafts,
-        changeNum
-      );
-      return this._changeComments;
-    });
+    return Promise.all(commentsPromise).then(
+      ([comments, robotComments, drafts, portedComments, portedDrafts]) => {
+        this._changeComments = new ChangeComments(
+          comments,
+          // TS 4.0.5 fails without 'as'
+          robotComments as PathToRobotCommentsInfoMap | undefined,
+          drafts,
+          portedComments,
+          portedDrafts
+        );
+        return this._changeComments;
+      }
+    );
   }
 
   /**
@@ -650,17 +652,30 @@
     if (!this._changeComments) {
       return this.loadAll(changeNum);
     }
-    const oldChangeComments = this._changeComments;
-    return this.$.restAPI.getDiffDrafts(changeNum).then(drafts => {
-      this._changeComments = new ChangeComments(
-        oldChangeComments.comments,
-        (oldChangeComments.robotComments as unknown) as PathToRobotCommentsInfoMap,
-        drafts,
-        changeNum
+    return this.restApiService.getDiffDrafts(changeNum).then(drafts => {
+      this._changeComments = this._changeComments!.cloneWithUpdatedDrafts(
+        drafts
       );
       return this._changeComments;
     });
   }
+
+  reloadPortedComments(changeNum: NumericChangeId, patchNum: PatchSetNum) {
+    if (!this._changeComments) {
+      this.loadAll(changeNum);
+      return Promise.resolve();
+    }
+    return Promise.all([
+      this.restApiService.getPortedComments(changeNum, patchNum),
+      this.restApiService.getPortedDrafts(changeNum, patchNum),
+    ]).then(res => {
+      if (!this._changeComments) return;
+      this._changeComments = this._changeComments.cloneWithUpdatedPortedComments(
+        res[0],
+        res[1]
+      );
+    });
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.ts
index 91d8b41..1489006 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.ts
@@ -16,6 +16,4 @@
  */
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
+export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
index be6f646..fbbe1fb 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
@@ -18,6 +18,10 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-comment-api.js';
 import {ChangeComments} from './gr-comment-api.js';
+import {isInRevisionOfPatchRange, isInBaseOfPatchRange, isDraftThread, isUnresolved, createCommentThreads} from '../../../utils/comment-util.js';
+import {createDraft, createComment, createChangeComments, createCommentThread} from '../../../test/test-data-generators.js';
+import {CommentSide, Side} from '../../../constants/constants.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-comment-api');
 
@@ -33,24 +37,20 @@
   test('loads logged-out', () => {
     const changeNum = 1234;
 
-    sinon.stub(element.$.restAPI, 'getLoggedIn')
-        .returns(Promise.resolve(false));
-    sinon.stub(element.$.restAPI, 'getDiffComments')
-        .returns(Promise.resolve({
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
+        Promise.resolve({
           'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
         }));
-    sinon.stub(element.$.restAPI, 'getDiffRobotComments')
-        .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-    sinon.stub(element.$.restAPI, 'getDiffDrafts')
-        .returns(Promise.resolve({}));
+    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
+        Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
+    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+        Promise.resolve({}));
 
     return element.loadAll(changeNum).then(() => {
-      assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
-          changeNum));
-      assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
-          changeNum));
-      assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
-          changeNum));
+      assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
+      assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
+      assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
       assert.isOk(element._changeComments._comments);
       assert.isOk(element._changeComments._robotComments);
       assert.deepEqual(element._changeComments._drafts, {});
@@ -60,24 +60,20 @@
   test('loads logged-in', () => {
     const changeNum = 1234;
 
-    sinon.stub(element.$.restAPI, 'getLoggedIn')
-        .returns(Promise.resolve(true));
-    sinon.stub(element.$.restAPI, 'getDiffComments')
-        .returns(Promise.resolve({
+    const getCommentsStub = stubRestApi('getDiffComments').returns(
+        Promise.resolve({
           'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
-        }));
-    sinon.stub(element.$.restAPI, 'getDiffRobotComments')
+        })
+    );
+    const getRobotCommentsStub = stubRestApi('getDiffRobotComments')
         .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-    sinon.stub(element.$.restAPI, 'getDiffDrafts')
+    const getDraftsStub = stubRestApi('getDiffDrafts')
         .returns(Promise.resolve({'foo.c': [{id: '555', message: 'ack'}]}));
 
     return element.loadAll(changeNum).then(() => {
-      assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
-          changeNum));
-      assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
-          changeNum));
-      assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
-          changeNum));
+      assert.isTrue(getCommentsStub.calledWithExactly(changeNum));
+      assert.isTrue(getRobotCommentsStub.calledWithExactly(changeNum));
+      assert.isTrue(getDraftsStub.calledWithExactly(changeNum));
       assert.isOk(element._changeComments._comments);
       assert.isOk(element._changeComments._robotComments);
       assert.notDeepEqual(element._changeComments._drafts, {});
@@ -89,11 +85,11 @@
     let robotCommentStub;
     let draftStub;
     setup(() => {
-      commentStub = sinon.stub(element.$.restAPI, 'getDiffComments')
+      commentStub = stubRestApi('getDiffComments')
           .returns(Promise.resolve({}));
-      robotCommentStub = sinon.stub(element.$.restAPI,
+      robotCommentStub = stubRestApi(
           'getDiffRobotComments').returns(Promise.resolve({}));
-      draftStub = sinon.stub(element.$.restAPI, 'getDiffDrafts')
+      draftStub = stubRestApi('getDiffDrafts')
           .returns(Promise.resolve({}));
     });
 
@@ -133,190 +129,488 @@
   suite('_changeComment methods', () => {
     setup(done => {
       const changeNum = 1234;
-      stub('gr-rest-api-interface', {
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-      });
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       element.loadAll(changeNum).then(() => {
         done();
       });
     });
 
+    suite('ported comments', () => {
+      let portedComments;
+      let changeComments;
+      const comment1 = {
+        ...createComment(),
+        unresolved: true,
+        id: '1',
+        line: 136,
+        patch_set: 2,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 1,
+          end_character: 1,
+        },
+      };
+
+      const comment2 = {
+        ...createComment(),
+        patch_set: 2,
+        id: '2',
+        line: 5,
+      };
+
+      const comment3 = {
+        ...createComment(),
+        side: CommentSide.PARENT,
+        line: 10,
+        unresolved: true,
+      };
+
+      const comment4 = {
+        ...comment3,
+        parent: -2,
+      };
+
+      const draft1 = {
+        ...createDraft(),
+        id: 'db977012_e1f13828',
+        line: 4,
+        patch_set: 2,
+      };
+      const draft2 = {
+        ...createDraft(),
+        id: '503008e2_0ab203ee',
+        line: 11,
+        unresolved: true,
+        // slightly larger timestamp so it's sorted higher
+        updated: '2018-02-13 22:49:48.018000001',
+        patch_set: 2,
+      };
+
+      setup(() => {
+        portedComments = {
+          'karma.conf.js': [{
+            ...comment1,
+            patch_set: 4,
+            range: {
+              start_line: 136,
+              start_character: 16,
+              end_line: 136,
+              end_character: 29,
+            },
+          }],
+        };
+
+        changeComments = new ChangeComments(
+            {/* comments */
+              'karma.conf.js': [
+                // resolved comment that will not be ported over
+                comment2,
+                // original comment that will be ported over to patchset 4
+                comment1,
+              ],
+            },
+            {}/* robot comments */,
+            {}/* drafts */,
+            portedComments,
+            {}/* ported drafts */
+        );
+      });
+
+      test('threads containing ported comment are returned', () => {
+        assert.equal(changeComments.getAllThreadsForChange().length,
+            2);
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
+
+        assert.equal(portedThreads.length, 1);
+        // check range of thread is from the ported comment and not the original
+        assert.deepEqual(portedThreads[0].range, {
+          start_line: 136,
+          start_character: 16,
+          end_line: 136,
+          end_character: 29,
+        });
+
+        // thread ported over if comparing patchset 1 vs patchset 4
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 1}
+        ).length, 1);
+
+        // verify ported thread is not returned if original thread will be
+        // shown
+        // original thread attached to right side
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 2, basePatchNum: 'PARENT'}
+        ).length, 0);
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 2, basePatchNum: 1}
+        ).length, 0);
+
+        // original thread attached to left side
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 3, basePatchNum: 2}
+        ).length, 0);
+      });
+
+      test('threads without any ported comment are filtered out', () => {
+        changeComments = new ChangeComments(
+            {/* comments */
+              // comment that is not ported over
+              'karma.conf.js': [comment2],
+            },
+            {}/* robot comments */,
+            {/* drafts */
+              'karma.conf.js': [draft2],
+            },
+            // comment1 that is ported over but does not have any thread
+            // that has a comment that matches it
+            portedComments,
+            {}/* ported drafts */
+        );
+
+        assert.equal(createCommentThreads(changeComments
+            .getAllCommentsForPath('karma.conf.js')).length, 1);
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'}
+        ).length, 0);
+      });
+
+      test('comments with side=PARENT are ported over', () => {
+        changeComments = new ChangeComments(
+            {/* comments */
+              // comment left on Base
+              'karma.conf.js': [comment3],
+            },
+            {}/* robot comments */,
+            {/* drafts */
+              'karma.conf.js': [draft2],
+            },
+            {/* ported comments */
+              'karma.conf.js': [{
+                ...comment3,
+                line: 31,
+                patch_set: 4,
+              }],
+            },
+            {}/* ported drafts */
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
+        assert.equal(portedThreads.length, 1);
+        assert.equal(portedThreads[0].line, 31);
+        assert.equal(portedThreads[0].diffSide, Side.LEFT);
+
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: -2}
+        ).length, 0);
+
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 2}
+        ).length, 0);
+      });
+
+      test('comments left on merge parent is not ported over', () => {
+        changeComments = new ChangeComments(
+            {/* comments */
+              // comment left on Base
+              'karma.conf.js': [comment4],
+            },
+            {}/* robot comments */,
+            {/* drafts */
+              'karma.conf.js': [draft2],
+            },
+            {/* ported comments */
+              'karma.conf.js': [{
+                ...comment4,
+                line: 31,
+                patch_set: 4,
+              }],
+            },
+            {}/* ported drafts */
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
+        assert.equal(portedThreads.length, 0);
+
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: -2}
+        ).length, 0);
+
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 2}
+        ).length, 0);
+      });
+
+      test('ported comments contribute to comment count', () => {
+        assert.equal(changeComments.computeCommentsString(
+            {basePatchNum: 'PARENT', patchNum: 2}, 'karma.conf.js',
+            {__path: 'karma.conf.js'}), '2 comments (1 unresolved)');
+
+        // comment1 is ported over to patchset 4
+        assert.equal(changeComments.computeCommentsString(
+            {basePatchNum: 'PARENT', patchNum: 4}, 'karma.conf.js',
+            {__path: 'karma.conf.js'}), '1 comment (1 unresolved)');
+      });
+
+      test('drafts are ported over', () => {
+        changeComments = new ChangeComments(
+            {}/* comments */,
+            {}/* robotComments */,
+            {/* drafts */
+              // draft1: resolved draft that will be ported over to ps 4
+              // draft2: unresolved draft that will be ported over to ps 4
+              'karma.conf.js': [draft1, draft2],
+            },
+            {}/* ported comments */,
+            {/* ported drafts */
+              'karma.conf.js': [
+                {
+                  ...draft1,
+                  line: 5,
+                  patch_set: 4,
+                },
+                {
+                  ...draft2,
+                  line: 31,
+                  patch_set: 4,
+                },
+              ],
+            }
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
+
+        // resolved draft is ported over
+        assert.equal(portedThreads.length, 2);
+        assert.equal(portedThreads[0].line, 5);
+        assert.isTrue(isDraftThread(portedThreads[0]));
+        assert.isFalse(isUnresolved(portedThreads[0]));
+
+        // unresolved draft is ported over
+        assert.equal(portedThreads[1].line, 31);
+        assert.isTrue(isDraftThread(portedThreads[1]));
+        assert.isTrue(isUnresolved(portedThreads[1]));
+
+        assert.equal(createCommentThreads(
+            changeComments.getAllCommentsForPath('karma.conf.js'),
+            {patchNum: 4, basePatchNum: 'PARENT'}).length, 0);
+      });
+    });
+
     test('_isInBaseOfPatchRange', () => {
       const comment = {patch_set: 1};
       const patchRange = {basePatchNum: 1, patchNum: 2};
-      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+      assert.isTrue(isInBaseOfPatchRange(comment,
           patchRange));
 
       patchRange.basePatchNum = PARENT;
-      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+      assert.isFalse(isInBaseOfPatchRange(comment,
           patchRange));
 
       comment.side = PARENT;
-      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+      assert.isFalse(isInBaseOfPatchRange(comment,
           patchRange));
 
       comment.patch_set = 2;
-      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+      assert.isTrue(isInBaseOfPatchRange(comment,
           patchRange));
 
       patchRange.basePatchNum = -2;
       comment.side = PARENT;
       comment.parent = 1;
-      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+      assert.isFalse(isInBaseOfPatchRange(comment,
           patchRange));
 
       comment.parent = 2;
-      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+      assert.isTrue(isInBaseOfPatchRange(comment,
           patchRange));
     });
 
-    test('_isInRevisionOfPatchRange', () => {
+    test('isInRevisionOfPatchRange', () => {
       const comment = {patch_set: 123};
       const patchRange = {basePatchNum: 122, patchNum: 124};
-      assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
+      assert.isFalse(isInRevisionOfPatchRange(
           comment, patchRange));
 
       patchRange.patchNum = 123;
-      assert.isTrue(element._changeComments._isInRevisionOfPatchRange(
+      assert.isTrue(isInRevisionOfPatchRange(
           comment, patchRange));
 
       comment.side = PARENT;
-      assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
+      assert.isFalse(isInRevisionOfPatchRange(
           comment, patchRange));
     });
 
-    test('_isInPatchRange', () => {
-      const patchRange1 = {basePatchNum: 122, patchNum: 124};
-      const patchRange2 = {basePatchNum: 123, patchNum: 125};
-      const patchRange3 = {basePatchNum: 124, patchNum: 125};
-
-      const isInBasePatchStub = sinon.stub(element._changeComments,
-          '_isInBaseOfPatchRange');
-      const isInRevisionPatchStub = sinon.stub(element._changeComments,
-          '_isInRevisionOfPatchRange');
-
-      isInBasePatchStub.withArgs({}, patchRange1).returns(true);
-      isInBasePatchStub.withArgs({}, patchRange2).returns(false);
-      isInBasePatchStub.withArgs({}, patchRange3).returns(false);
-
-      isInRevisionPatchStub.withArgs({}, patchRange1).returns(false);
-      isInRevisionPatchStub.withArgs({}, patchRange2).returns(true);
-      isInRevisionPatchStub.withArgs({}, patchRange3).returns(false);
-
-      assert.isTrue(element._changeComments._isInPatchRange({}, patchRange1));
-      assert.isTrue(element._changeComments._isInPatchRange({}, patchRange2));
-      assert.isFalse(element._changeComments._isInPatchRange({},
-          patchRange3));
-    });
-
     suite('comment ranges and paths', () => {
+      const commentObjs = {};
       function makeTime(mins) {
         return `2013-02-26 15:0${mins}:43.986000000`;
       }
 
       setup(() => {
+        commentObjs['01'] = {
+          ...createComment(),
+          id: '01',
+          patch_set: 2,
+          side: PARENT,
+          line: 1,
+          updated: makeTime(1),
+          range: {
+            start_line: 1,
+            start_character: 2,
+            end_line: 2,
+            end_character: 2,
+          },
+        };
+
+        commentObjs['02'] = {
+          ...createComment(),
+          id: '02',
+          in_reply_to: '04',
+          patch_set: 2,
+          unresolved: true,
+          line: 1,
+          updated: makeTime(3),
+        };
+
+        commentObjs['03'] = {
+          ...createComment(),
+          id: '03',
+          patch_set: 2,
+          side: PARENT,
+          line: 2,
+          updated: makeTime(1),
+        };
+
+        commentObjs['04'] = {
+          ...createComment(),
+          id: '04',
+          patch_set: 2,
+          line: 1,
+          updated: makeTime(1),
+        };
+
+        commentObjs['05'] = {
+          ...createComment(),
+          id: '05',
+          patch_set: 2,
+          line: 2,
+          updated: makeTime(1),
+        };
+
+        commentObjs['06'] = {
+          ...createComment(),
+          id: '06',
+          patch_set: 3,
+          line: 2,
+          updated: makeTime(1),
+        };
+
+        commentObjs['07'] = {
+          ...createComment(),
+          id: '07',
+          patch_set: 2,
+          side: PARENT,
+          unresolved: false,
+          line: 1,
+          updated: makeTime(1),
+        };
+
+        commentObjs['08'] = {
+          ...createComment(),
+          id: '08',
+          patch_set: 2,
+          side: PARENT,
+          unresolved: true,
+          in_reply_to: '07',
+          line: 1,
+          updated: makeTime(1),
+        };
+
+        commentObjs['09'] = {
+          ...createComment(),
+          id: '09',
+          patch_set: 3,
+          line: 1,
+          updated: makeTime(1),
+        };
+
+        commentObjs['10'] = {
+          ...createComment(),
+          id: '10',
+          patch_set: 5,
+          side: PARENT,
+          line: 1,
+          updated: makeTime(1),
+        };
+
+        commentObjs['11'] = {
+          ...createComment(),
+          id: '11',
+          patch_set: 5,
+          line: 1,
+          updated: makeTime(1),
+        };
+
+        commentObjs['12'] = {
+          ...createDraft(),
+          id: '12',
+          patch_set: 2,
+          side: PARENT,
+          line: 1,
+          updated: makeTime(3),
+        };
+
+        commentObjs['13'] = {
+          ...createDraft(),
+          id: '13',
+          in_reply_to: '04',
+          patch_set: 2,
+          line: 1,
+          // Draft gets lower timestamp than published comment, because we
+          // want to test that the draft still gets sorted to the end.
+          updated: makeTime(2),
+        };
+
+        commentObjs['14'] = {
+          ...createDraft(),
+          id: '14',
+          patch_set: 3,
+          line: 1,
+          path: 'file/two',
+          updated: makeTime(3),
+        };
+
         const drafts = {
           'file/one': [
-            {
-              id: '12',
-              patch_set: 2,
-              side: PARENT,
-              line: 1,
-              updated: makeTime(3),
-            },
-            {
-              id: '13',
-              in_reply_to: '04',
-              patch_set: 2,
-              line: 1,
-              // Draft gets lower timestamp than published comment, because we
-              // want to test that the draft still gets sorted to the end.
-              updated: makeTime(2),
-            },
+            commentObjs['12'],
+            commentObjs['13'],
           ],
           'file/two': [
-            {
-              id: '05',
-              patch_set: 3,
-              line: 1,
-              updated: makeTime(3),
-            },
+            commentObjs['14'],
           ],
         };
         const robotComments = {
           'file/one': [
-            {
-              id: '01',
-              patch_set: 2,
-              side: PARENT,
-              line: 1,
-              updated: makeTime(1),
-              range: {
-                start_line: 1,
-                start_character: 2,
-                end_line: 2,
-                end_character: 2,
-              },
-            }, {
-              id: '02',
-              in_reply_to: '04',
-              patch_set: 2,
-              unresolved: true,
-              line: 1,
-              updated: makeTime(3),
-            },
+            commentObjs['01'], commentObjs['02'],
           ],
         };
         const comments = {
-          'file/one': [
-            {
-              id: '03',
-              patch_set: 2,
-              side: PARENT,
-              line: 2,
-              updated: makeTime(1),
-            },
-            {id: '04', patch_set: 2, line: 1, updated: makeTime(1)},
-          ],
-          'file/two': [
-            {id: '05', patch_set: 2, line: 2, updated: makeTime(1)},
-            {id: '06', patch_set: 3, line: 2, updated: makeTime(1)},
-          ],
-          'file/three': [
-            {
-              id: '07',
-              patch_set: 2,
-              side: PARENT,
-              unresolved: false,
-              line: 1,
-              updated: makeTime(1),
-            },
-            {
-              id: '08',
-              patch_set: 2,
-              side: PARENT,
-              unresolved: true,
-              in_reply_to: '07',
-              line: 1,
-              updated: makeTime(1),
-            },
-            {id: '09', patch_set: 3, line: 1, updated: makeTime(1)},
-          ],
-          'file/four': [
-            {
-              id: '10',
-              patch_set: 5,
-              side: PARENT,
-              line: 1,
-              updated: makeTime(1),
-            },
-            {id: '11', patch_set: 5, line: 1, updated: makeTime(1)},
-          ],
+          'file/one': [commentObjs['03'], commentObjs['04']],
+          'file/two': [commentObjs['05'], commentObjs['06']],
+          'file/three': [commentObjs['07'], commentObjs['08'],
+            commentObjs['09']],
+          'file/four': [commentObjs['10'], commentObjs['11']],
         };
         element._changeComments =
-            new ChangeComments(comments, robotComments, drafts, 1234);
+            new ChangeComments(comments, robotComments, drafts, {}, {});
       });
 
       test('getPaths', () => {
@@ -346,33 +640,40 @@
         assert.property(paths, 'file/four');
       });
 
-      test('getCommentsBySideForPath', () => {
+      test('getCommentsForPath', () => {
         const patchRange = {basePatchNum: 1, patchNum: 3};
         let path = 'file/one';
-        let comments = element._changeComments.getCommentsBySideForPath(path,
+        let comments = element._changeComments.getCommentsForPath(path,
             patchRange);
-        assert.equal(comments.meta.changeNum, 1234);
-        assert.equal(comments.left.length, 0);
-        assert.equal(comments.right.length, 0);
+        assert.equal(comments.filter(c => isInBaseOfPatchRange(c, patchRange))
+            .length, 0);
+        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
+            patchRange)).length, 0);
 
         path = 'file/two';
-        comments = element._changeComments.getCommentsBySideForPath(path,
+        comments = element._changeComments.getCommentsForPath(path,
             patchRange);
-        assert.equal(comments.left.length, 0);
-        assert.equal(comments.right.length, 2);
+        assert.equal(comments.filter(c => isInBaseOfPatchRange(c, patchRange))
+            .length, 0);
+        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
+            patchRange)).length, 2);
 
         patchRange.basePatchNum = 2;
-        comments = element._changeComments.getCommentsBySideForPath(path,
+        comments = element._changeComments.getCommentsForPath(path,
             patchRange);
-        assert.equal(comments.left.length, 1);
-        assert.equal(comments.right.length, 2);
+        assert.equal(comments.filter(c => isInBaseOfPatchRange(c,
+            patchRange)).length, 1);
+        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
+            patchRange)).length, 2);
 
         patchRange.basePatchNum = PARENT;
         path = 'file/three';
-        comments = element._changeComments.getCommentsBySideForPath(path,
+        comments = element._changeComments.getCommentsForPath(path,
             patchRange);
-        assert.equal(comments.left.length, 0);
-        assert.equal(comments.right.length, 1);
+        assert.equal(comments.filter(c => isInBaseOfPatchRange(c, patchRange))
+            .length, 0);
+        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
+            patchRange)).length, 1);
       });
 
       test('getAllCommentsForPath', () => {
@@ -448,6 +749,78 @@
             element._changeComments.computeUnresolvedNum(1, 'path'), 0);
       });
 
+      test('computeCommentsString', () => {
+        const changeComments = createChangeComments();
+        const parentTo1 = {
+          basePatchNum: 'PARENT',
+          patchNum: 1,
+        };
+        const parentTo2 = {
+          basePatchNum: 'PARENT',
+          patchNum: 2,
+        };
+        const _1To2 = {
+          basePatchNum: 1,
+          patchNum: 2,
+        };
+
+        assert.equal(
+            changeComments.computeCommentsString(parentTo1, '/COMMIT_MSG',
+                {__path: '/COMMIT_MSG'}), '2 comments (1 unresolved)');
+        assert.equal(
+            changeComments.computeCommentsString(parentTo1, '/COMMIT_MSG',
+                {__path: '/COMMIT_MSG', status: 'U'}, true),
+            '2 comments (1 unresolved)(no changes)');
+        assert.equal(
+            changeComments.computeCommentsString(_1To2, '/COMMIT_MSG',
+                {__path: '/COMMIT_MSG'}), '3 comments (1 unresolved)');
+
+        assert.equal(
+            changeComments.computeCommentsString(parentTo1, 'myfile.txt',
+                {__path: 'myfile.txt'}), '1 comment');
+        assert.equal(
+            changeComments.computeCommentsString(_1To2, 'myfile.txt',
+                {__path: 'myfile.txt'}), '3 comments');
+
+        assert.equal(
+            changeComments.computeCommentsString(parentTo1,
+                'file_added_in_rev2.txt',
+                {__path: 'file_added_in_rev2.txt'}), '');
+        assert.equal(
+            changeComments.computeCommentsString(_1To2,
+                'file_added_in_rev2.txt',
+                {__path: 'file_added_in_rev2.txt'}), '');
+
+        assert.equal(
+            changeComments.computeCommentsString(parentTo2, '/COMMIT_MSG',
+                {__path: '/COMMIT_MSG'}), '1 comment');
+        assert.equal(
+            changeComments.computeCommentsString(_1To2, '/COMMIT_MSG',
+                {__path: '/COMMIT_MSG'}), '3 comments (1 unresolved)');
+
+        assert.equal(
+            changeComments.computeCommentsString(parentTo2, 'myfile.txt',
+                {__path: 'myfile.txt'}), '2 comments');
+        assert.equal(
+            changeComments.computeCommentsString(_1To2, 'myfile.txt',
+                {__path: 'myfile.txt'}), '3 comments');
+
+        assert.equal(
+            changeComments.computeCommentsString(parentTo2,
+                'file_added_in_rev2.txt',
+                {__path: 'file_added_in_rev2.txt'}), '');
+        assert.equal(
+            changeComments.computeCommentsString(_1To2,
+                'file_added_in_rev2.txt',
+                {__path: 'file_added_in_rev2.txt'}), '');
+        assert.equal(
+            changeComments.computeCommentsString(parentTo2, 'unresolved.file',
+                {__path: 'unresolved.file'}), '2 comments (1 unresolved)');
+        assert.equal(
+            changeComments.computeCommentsString(_1To2, 'unresolved.file',
+                {__path: 'unresolved.file'}), '2 comments (1 unresolved)');
+      });
+
       test('computeCommentThreadCount', () => {
         assert.equal(element._changeComments
             .computeCommentThreadCount({
@@ -521,220 +894,31 @@
       test('computeAllThreads', () => {
         const expectedThreads = [
           {
-            comments: [
-              {
-                id: '01',
-                patch_set: 2,
-                side: 'PARENT',
-                line: 1,
-                updated: '2013-02-26 15:01:43.986000000',
-                range: {
-                  start_line: 1,
-                  start_character: 2,
-                  end_line: 2,
-                  end_character: 2,
-                },
-                path: 'file/one',
-                __path: 'file/one',
-              },
-            ],
-            commentSide: 'PARENT',
-            patchNum: 2,
-            path: 'file/one',
-            line: 1,
-            rootId: '01',
+            ...createCommentThread([{...commentObjs['01'], path: 'file/one'}]),
           }, {
-            comments: [
-              {
-                id: '03',
-                patch_set: 2,
-                side: 'PARENT',
-                line: 2,
-                path: 'file/one',
-                __path: 'file/one',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            commentSide: 'PARENT',
-            patchNum: 2,
-            path: 'file/one',
-            line: 2,
-            rootId: '03',
+            ...createCommentThread([{...commentObjs['03'], path: 'file/one'}]),
           }, {
-            comments: [
-              {
-                id: '04',
-                patch_set: 2,
-                line: 1,
-                path: 'file/one',
-                __path: 'file/one',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-              {
-                id: '02',
-                in_reply_to: '04',
-                patch_set: 2,
-                unresolved: true,
-                line: 1,
-                path: 'file/one',
-                __path: 'file/one',
-                updated: '2013-02-26 15:03:43.986000000',
-              },
-              {
-                id: '13',
-                in_reply_to: '04',
-                patch_set: 2,
-                line: 1,
-                path: 'file/one',
-                __path: 'file/one',
-                __draft: true,
-                updated: '2013-02-26 15:02:43.986000000',
-              },
-            ],
-            patchNum: 2,
-            path: 'file/one',
-            line: 1,
-            rootId: '04',
+            ...createCommentThread([{...commentObjs['04'], path: 'file/one'},
+              {...commentObjs['02'], path: 'file/one'},
+              {...commentObjs['13'], path: 'file/one'}]),
           }, {
-            comments: [
-              {
-                id: '05',
-                patch_set: 2,
-                line: 2,
-                path: 'file/two',
-                __path: 'file/two',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            patchNum: 2,
-            path: 'file/two',
-            line: 2,
-            rootId: '05',
+            ...createCommentThread([{...commentObjs['05'], path: 'file/two'}]),
           }, {
-            comments: [
-              {
-                id: '06',
-                patch_set: 3,
-                line: 2,
-                path: 'file/two',
-                __path: 'file/two',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            patchNum: 3,
-            path: 'file/two',
-            line: 2,
-            rootId: '06',
+            ...createCommentThread([{...commentObjs['06'], path: 'file/two'}]),
           }, {
-            comments: [
-              {
-                id: '07',
-                patch_set: 2,
-                side: 'PARENT',
-                unresolved: false,
-                line: 1,
-                path: 'file/three',
-                __path: 'file/three',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-              {
-                id: '08',
-                in_reply_to: '07',
-                patch_set: 2,
-                side: 'PARENT',
-                unresolved: true,
-                line: 1,
-                path: 'file/three',
-                __path: 'file/three',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            commentSide: 'PARENT',
-            patchNum: 2,
-            path: 'file/three',
-            line: 1,
-            rootId: '07',
+            ...createCommentThread([{...commentObjs['07'], path: 'file/three'},
+              {...commentObjs['08'], path: 'file/three'}]),
           }, {
-            comments: [
-              {
-                id: '09',
-                patch_set: 3,
-                line: 1,
-                path: 'file/three',
-                __path: 'file/three',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            patchNum: 3,
-            path: 'file/three',
-            line: 1,
-            rootId: '09',
+            ...createCommentThread([{...commentObjs['09'], path: 'file/three'}]
+            ),
           }, {
-            comments: [
-              {
-                id: '10',
-                patch_set: 5,
-                side: 'PARENT',
-                line: 1,
-                path: 'file/four',
-                __path: 'file/four',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            commentSide: 'PARENT',
-            patchNum: 5,
-            path: 'file/four',
-            line: 1,
-            rootId: '10',
+            ...createCommentThread([{...commentObjs['10'], path: 'file/four'}]),
           }, {
-            comments: [
-              {
-                id: '11',
-                patch_set: 5,
-                line: 1,
-                path: 'file/four',
-                __path: 'file/four',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            rootId: '11',
-            patchNum: 5,
-            path: 'file/four',
-            line: 1,
+            ...createCommentThread([{...commentObjs['11'], path: 'file/four'}]),
           }, {
-            comments: [
-              {
-                id: '05',
-                patch_set: 3,
-                line: 1,
-                path: 'file/two',
-                __path: 'file/two',
-                __draft: true,
-                updated: '2013-02-26 15:03:43.986000000',
-              },
-            ],
-            rootId: '05',
-            patchNum: 3,
-            path: 'file/two',
-            line: 1,
+            ...createCommentThread([{...commentObjs['12'], path: 'file/one'}]),
           }, {
-            comments: [
-              {
-                id: '12',
-                patch_set: 2,
-                side: 'PARENT',
-                line: 1,
-                path: 'file/one',
-                __path: 'file/one',
-                __draft: true,
-                updated: '2013-02-26 15:03:43.986000000',
-              },
-            ],
-            rootId: '12',
-            commentSide: 'PARENT',
-            patchNum: 2,
-            path: 'file/one',
-            line: 1,
+            ...createCommentThread([{...commentObjs['14'], path: 'file/two'}]),
           },
         ];
         const threads = element._changeComments.getAllThreadsForChange();
@@ -743,48 +927,14 @@
 
       test('getCommentsForThreadGroup', () => {
         let expectedComments = [
-          {
-            __path: 'file/one',
-            path: 'file/one',
-            id: '04',
-            patch_set: 2,
-            line: 1,
-            updated: '2013-02-26 15:01:43.986000000',
-          },
-          {
-            __path: 'file/one',
-            path: 'file/one',
-            id: '02',
-            in_reply_to: '04',
-            patch_set: 2,
-            unresolved: true,
-            line: 1,
-            updated: '2013-02-26 15:03:43.986000000',
-          },
-          {
-            __path: 'file/one',
-            path: 'file/one',
-            __draft: true,
-            id: '13',
-            in_reply_to: '04',
-            patch_set: 2,
-            line: 1,
-            updated: '2013-02-26 15:02:43.986000000',
-          },
+          {...commentObjs['04'], path: 'file/one'},
+          {...commentObjs['02'], path: 'file/one'},
+          {...commentObjs['13'], path: 'file/one'},
         ];
         assert.deepEqual(element._changeComments.getCommentsForThread('04'),
             expectedComments);
 
-        expectedComments = [{
-          id: '12',
-          patch_set: 2,
-          side: 'PARENT',
-          line: 1,
-          path: 'file/one',
-          __path: 'file/one',
-          __draft: true,
-          updated: '2013-02-26 15:03:43.986000000',
-        }];
+        expectedComments = [{...commentObjs['12'], path: 'file/one'}];
 
         assert.deepEqual(element._changeComments.getCommentsForThread('12'),
             expectedComments);
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
index 6f9705f..f121113 100644
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-coverage-layer_html';
 import {CoverageRange, CoverageType, DiffLayer} from '../../../types/types';
@@ -35,9 +33,7 @@
 ]);
 
 @customElement('gr-coverage-layer')
-export class GrCoverageLayer
-  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
-  implements DiffLayer {
+export class GrCoverageLayer extends PolymerElement implements DiffLayer {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
index 7a26e77..763a524 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
@@ -16,7 +16,7 @@
  */
 
 import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 
 export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
index b464dce..2777639 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
@@ -19,8 +19,6 @@
 import '../../shared/gr-hovercard/gr-hovercard';
 import '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import './gr-diff-builder-side-by-side';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-builder-element_html';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
@@ -31,12 +29,8 @@
 import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
 import {CancelablePromise, util} from '../../../scripts/util';
 import {customElement, property, observe} from '@polymer/decorators';
-import {
-  BlameInfo,
-  DiffInfo,
-  DiffPreferencesInfo,
-  ImageInfo,
-} from '../../../types/common';
+import {BlameInfo, ImageInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {CoverageRange, DiffLayer} from '../../../types/types';
 import {
   GrDiffProcessor,
@@ -47,16 +41,13 @@
   GrRangedCommentLayer,
 } from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
+import {DiffViewMode, RenderPreferences} from '../../../api/diff';
 import {Side} from '../../../constants/constants';
 import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {getLineNumber} from '../gr-diff/gr-diff-utils';
-
-const DiffViewMode = {
-  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-  UNIFIED: 'UNIFIED_DIFF',
-};
+import {fireAlert, fireEvent} from '../../../utils/event-util';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
 
@@ -103,9 +94,7 @@
 }
 
 @customElement('gr-diff-builder')
-export class GrDiffBuilderElement extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffBuilderElement extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -180,7 +169,7 @@
   coverageRanges: CoverageRange[] = [];
 
   @property({type: Boolean})
-  useNewContextControls = false;
+  useNewImageDiffUi = false;
 
   @property({
     type: Array,
@@ -203,15 +192,16 @@
   _cancelableRenderPromise: CancelablePromise<unknown> | null = null;
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     if (this._builder) {
       this._builder.clear();
     }
+    super.disconnectedCallback();
   }
 
-  get diffElement() {
-    return this.queryEffectiveChildren('#diffTable') as HTMLTableElement;
+  get diffElement(): HTMLTableElement {
+    // Not searching in shadowRoot, because the diff table is slotted!
+    return this.querySelector('#diffTable') as HTMLTableElement;
   }
 
   _computeLeftCoverageRanges(coverageRanges: CoverageRange[]) {
@@ -222,7 +212,11 @@
     return coverageRanges.filter(range => range && range.side === 'right');
   }
 
-  render(keyLocations: KeyLocations, prefs: DiffPreferencesInfo) {
+  render(
+    keyLocations: KeyLocations,
+    prefs: DiffPreferencesInfo,
+    renderPrefs?: RenderPreferences
+  ) {
     // Setting up annotation layers must happen after plugins are
     // installed, and |render| satisfies the requirement, however,
     // |attached| doesn't because in the diff view page, the element is
@@ -241,7 +235,7 @@
     if (!this.diff) {
       throw Error('Cannot render a diff without DiffInfo.');
     }
-    this._builder = this._getDiffBuilder(this.diff, prefs);
+    this._builder = this._getDiffBuilder(this.diff, prefs, renderPrefs);
 
     this.$.processor.context = prefs.context;
     this.$.processor.keyLocations = keyLocations;
@@ -251,17 +245,13 @@
 
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
-    this.dispatchEvent(
-      new CustomEvent('render-start', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'render-start');
     this._cancelableRenderPromise = util.makeCancelable(
       this.$.processor.process(this.diff.content, isBinary).then(() => {
         if (this.isImageDiff) {
           (this._builder as GrDiffBuilderImage).renderDiff();
         }
-        this.dispatchEvent(
-          new CustomEvent('render-content', {bubbles: true, composed: true})
-        );
+        fireEvent(this, 'render-content');
       })
     );
     return (
@@ -339,7 +329,7 @@
     return this.getContentTdByLine(line, side, row);
   }
 
-  getLineElByNumber(lineNumber: string | number, side?: Side) {
+  getLineElByNumber(lineNumber: LineNumber, side?: Side) {
     const sideSelector = side ? '.' + side : '';
     return this.diffElement.querySelector(
       `.lineNum[data-value="${lineNumber}"]${sideSelector}`
@@ -369,16 +359,7 @@
       sectionEl.parentNode.removeChild(sectionEl);
     }
 
-    this.async(
-      () =>
-        this.dispatchEvent(
-          new CustomEvent('render-content', {
-            composed: true,
-            bubbles: true,
-          })
-        ),
-      1
-    );
+    setTimeout(() => fireEvent(this, 'render-content'), 1);
   }
 
   cancel() {
@@ -393,19 +374,15 @@
     const message =
       `The value of the '${pref}' user preference is ` +
       'invalid. Fix in diff preferences';
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {
-          message,
-        },
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireAlert(this, message);
     throw Error(`Invalid preference value: ${pref}`);
   }
 
-  _getDiffBuilder(diff: DiffInfo, prefs: DiffPreferencesInfo): GrDiffBuilder {
+  _getDiffBuilder(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    renderPrefs?: RenderPreferences
+  ): GrDiffBuilder {
     if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
       this._handlePreferenceError('tab size');
     }
@@ -428,7 +405,9 @@
         localPrefs,
         this.diffElement,
         this.baseImage,
-        this.revisionImage
+        this.revisionImage,
+        renderPrefs,
+        this.useNewImageDiffUi
       );
     } else if (diff.binary) {
       // If the diff is binary, but not an image.
@@ -439,7 +418,7 @@
         localPrefs,
         this.diffElement,
         this._layers,
-        this.useNewContextControls
+        renderPrefs
       );
     } else if (this.viewMode === DiffViewMode.UNIFIED) {
       builder = new GrDiffBuilderUnified(
@@ -447,7 +426,7 @@
         localPrefs,
         this.diffElement,
         this._layers,
-        this.useNewContextControls
+        renderPrefs
       );
     }
     if (!builder) {
@@ -539,9 +518,7 @@
   }
 
   _createTrailingWhitespaceLayer(): DiffLayer {
-    const show = () => {
-      return this._showTrailingWhitespace;
-    };
+    const show = () => this._showTrailingWhitespace;
 
     return {
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
index b10b251..7c01a95 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
@@ -18,7 +18,6 @@
 import '../../../test/common-test-setup-karma.js';
 import '../gr-diff/gr-diff-group.js';
 import './gr-diff-builder.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import './gr-diff-builder-element.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
@@ -29,6 +28,8 @@
 import {GrDiffBuilder} from './gr-diff-builder.js';
 import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {DiffViewMode} from '../../../api/diff.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromTemplate(html`
     <gr-diff-builder>
@@ -46,11 +47,6 @@
     </gr-diff-builder>
 `);
 
-const DiffViewMode = {
-  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-  UNIFIED: 'UNIFIED_DIFF',
-};
-
 suite('gr-diff-builder tests', () => {
   let prefs;
   let element;
@@ -60,10 +56,8 @@
 
   setup(() => {
     element = basicFixture.instantiate();
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      getProjectConfig() { return Promise.resolve({}); },
-    });
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getProjectConfig').returns(Promise.resolve({}));
     stubBaseUrl('/r');
     prefs = {
       line_length: 10,
@@ -103,106 +97,57 @@
       return section;
     }
 
-    suite('old style', () => {
-      setup(() => {
-        builder = new GrDiffBuilder(
-            {content: []}, prefs, null, [], false /* useNewContextControls */);
-      });
-
-      test('no +10 buttons for 10 or less lines', () => {
-        const section = createContextSectionForGroups({count: 10});
-        const buttons = section.querySelectorAll('gr-button.showContext');
-
-        assert.equal(buttons.length, 1);
-        assert.equal(buttons[0].textContent, 'Show 10 common lines');
-      });
-
-      test('context control at the top', () => {
-        builder._numLinesLeft = 50;
-        const section = createContextSectionForGroups({offset: 0, count: 20});
-        const buttons = section.querySelectorAll('gr-button.showContext');
-
-        assert.equal(buttons.length, 2);
-        assert.equal(buttons[0].textContent, 'Show 20 common lines');
-        assert.equal(buttons[1].textContent, '+10 below');
-      });
-
-      test('context control in the middle', () => {
-        builder._numLinesLeft = 50;
-        const section = createContextSectionForGroups({offset: 10, count: 20});
-        const buttons = section.querySelectorAll('gr-button.showContext');
-
-        assert.equal(buttons.length, 3);
-        assert.equal(buttons[0].textContent, '+10 above');
-        assert.equal(buttons[1].textContent, 'Show 20 common lines');
-        assert.equal(buttons[2].textContent, '+10 below');
-      });
-
-      test('context control at the bottom', () => {
-        builder._numLinesLeft = 50;
-        const section = createContextSectionForGroups({offset: 30, count: 20});
-        const buttons = section.querySelectorAll('gr-button.showContext');
-
-        assert.equal(buttons.length, 2);
-        assert.equal(buttons[0].textContent, '+10 above');
-        assert.equal(buttons[1].textContent, 'Show 20 common lines');
-      });
+    setup(() => {
+      builder = new GrDiffBuilder({content: []}, prefs, null, []);
     });
 
-    suite('new style', () => {
-      setup(() => {
-        builder = new GrDiffBuilder(
-            {content: []}, prefs, null, [], true /* useNewContextControls */);
-      });
+    test('no +10 buttons for 10 or less lines', () => {
+      const section = createContextSectionForGroups({count: 10});
+      const buttons = section.querySelectorAll('gr-button.showContext');
 
-      test('no +10 buttons for 10 or less lines', () => {
-        const section = createContextSectionForGroups({count: 10});
-        const buttons = section.querySelectorAll('gr-button.showContext');
+      assert.equal(buttons.length, 1);
+      assert.equal(buttons[0].textContent, '+10 common lines');
+    });
 
-        assert.equal(buttons.length, 1);
-        assert.equal(buttons[0].textContent, '+10 common lines');
-      });
+    test('context control at the top', () => {
+      builder._numLinesLeft = 50;
+      const section = createContextSectionForGroups({offset: 0, count: 20});
+      const buttons = section.querySelectorAll('gr-button.showContext');
 
-      test('context control at the top', () => {
-        builder._numLinesLeft = 50;
-        const section = createContextSectionForGroups({offset: 0, count: 20});
-        const buttons = section.querySelectorAll('gr-button.showContext');
+      assert.equal(buttons.length, 2);
+      assert.equal(buttons[0].textContent, '+20 common lines');
+      assert.equal(buttons[1].textContent, '+10');
 
-        assert.equal(buttons.length, 2);
-        assert.equal(buttons[0].textContent, '+20 common lines');
-        assert.equal(buttons[1].textContent, '+10');
+      assert.include([...buttons[0].classList.values()], 'belowButton');
+      assert.include([...buttons[1].classList.values()], 'belowButton');
+    });
 
-        assert.include([...buttons[0].classList.values()], 'belowButton');
-        assert.include([...buttons[1].classList.values()], 'belowButton');
-      });
+    test('context control in the middle', () => {
+      builder._numLinesLeft = 50;
+      const section = createContextSectionForGroups({offset: 10, count: 20});
+      const buttons = section.querySelectorAll('gr-button.showContext');
 
-      test('context control in the middle', () => {
-        builder._numLinesLeft = 50;
-        const section = createContextSectionForGroups({offset: 10, count: 20});
-        const buttons = section.querySelectorAll('gr-button.showContext');
+      assert.equal(buttons.length, 3);
+      assert.equal(buttons[0].textContent, '+20 common lines');
+      assert.equal(buttons[1].textContent, '+10');
+      assert.equal(buttons[2].textContent, '+10');
 
-        assert.equal(buttons.length, 3);
-        assert.equal(buttons[0].textContent, '+20 common lines');
-        assert.equal(buttons[1].textContent, '+10');
-        assert.equal(buttons[2].textContent, '+10');
+      assert.include([...buttons[0].classList.values()], 'centeredButton');
+      assert.include([...buttons[1].classList.values()], 'aboveButton');
+      assert.include([...buttons[2].classList.values()], 'belowButton');
+    });
 
-        assert.include([...buttons[0].classList.values()], 'centeredButton');
-        assert.include([...buttons[1].classList.values()], 'aboveButton');
-        assert.include([...buttons[2].classList.values()], 'belowButton');
-      });
+    test('context control at the bottom', () => {
+      builder._numLinesLeft = 50;
+      const section = createContextSectionForGroups({offset: 30, count: 20});
+      const buttons = section.querySelectorAll('gr-button.showContext');
 
-      test('context control at the bottom', () => {
-        builder._numLinesLeft = 50;
-        const section = createContextSectionForGroups({offset: 30, count: 20});
-        const buttons = section.querySelectorAll('gr-button.showContext');
+      assert.equal(buttons.length, 2);
+      assert.equal(buttons[0].textContent, '+20 common lines');
+      assert.equal(buttons[1].textContent, '+10');
 
-        assert.equal(buttons.length, 2);
-        assert.equal(buttons[0].textContent, '+20 common lines');
-        assert.equal(buttons[1].textContent, '+10');
-
-        assert.include([...buttons[0].classList.values()], 'aboveButton');
-        assert.include([...buttons[1].classList.values()], 'aboveButton');
-      });
+      assert.include([...buttons[0].classList.values()], 'aboveButton');
+      assert.include([...buttons[1].classList.values()], 'aboveButton');
     });
   });
 
@@ -898,7 +843,7 @@
         },
       ];
       element = basicFixture.instantiate();
-      outputEl = element.queryEffectiveChildren('#diffTable');
+      outputEl = element.querySelector('#diffTable');
       keyLocations = {left: {}, right: {}};
       sinon.stub(element, '_getDiffBuilder').callsFake(() => {
         const builder = new GrDiffBuilderSideBySide({content}, prefs, outputEl);
@@ -921,7 +866,7 @@
     });
 
     test('getSectionsByLineRange one line', () => {
-      const section = outputEl.querySelector('stub:nth-of-type(2)');
+      const section = outputEl.querySelector('stub:nth-of-type(3)');
       const sections = element._builder.getSectionsByLineRange(1, 1, 'left');
       assert.equal(sections.length, 1);
       assert.strictEqual(sections[0], section);
@@ -929,8 +874,8 @@
 
     test('getSectionsByLineRange over diff', () => {
       const section = [
-        outputEl.querySelector('stub:nth-of-type(2)'),
         outputEl.querySelector('stub:nth-of-type(3)'),
+        outputEl.querySelector('stub:nth-of-type(4)'),
       ];
       const sections = element._builder.getSectionsByLineRange(1, 2, 'left');
       assert.equal(sections.length, 2);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
index 15264ea..650a9e7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -16,8 +16,12 @@
  */
 
 import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
-import {DiffInfo, DiffPreferencesInfo, ImageInfo} from '../../../types/common';
+import {ImageInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrEndpointParam} from '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import {RenderPreferences} from '../../../api/diff';
+import '../gr-diff-image-viewer/gr-image-viewer';
+import {GrImageViewer} from '../gr-diff-image-viewer/gr-image-viewer';
 
 // MIME types for images we allow showing. Do not include SVG, it can contain
 // arbitrary JavaScript.
@@ -29,19 +33,27 @@
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
     private readonly _baseImage: ImageInfo | null,
-    private readonly _revisionImage: ImageInfo | null
+    private readonly _revisionImage: ImageInfo | null,
+    renderPrefs?: RenderPreferences,
+    private readonly _useNewImageDiffUi: boolean = false
   ) {
-    super(diff, prefs, outputEl, []);
+    super(diff, prefs, outputEl, [], renderPrefs);
   }
 
   public renderDiff() {
     const section = this._createElement('tbody', 'image-diff');
 
-    this._emitImagePair(section);
-    this._emitImageLabels(section);
+    if (this._useNewImageDiffUi) {
+      this._emitImageViewer(section);
 
-    this._outputEl.appendChild(section);
-    this._outputEl.appendChild(this._createEndpoint());
+      this._outputEl.appendChild(section);
+    } else {
+      this._emitImagePair(section);
+      this._emitImageLabels(section);
+
+      this._outputEl.appendChild(section);
+      this._outputEl.appendChild(this._createEndpoint());
+    }
   }
 
   private _createEndpoint() {
@@ -75,6 +87,27 @@
     return endpointParam;
   }
 
+  private _emitImageViewer(section: HTMLElement) {
+    const tr = this._createElement('tr');
+    const td = this._createElement('td');
+    // TODO(hermannloose): Support blame for image diffs, see above.
+    td.setAttribute('colspan', '4');
+    const imageViewer = this._createElement('gr-image-viewer') as GrImageViewer;
+
+    imageViewer.baseUrl = this._getImageSrc(this._baseImage);
+    imageViewer.revisionUrl = this._getImageSrc(this._revisionImage);
+
+    td.appendChild(imageViewer);
+    tr.appendChild(td);
+    section.appendChild(tr);
+  }
+
+  private _getImageSrc(image: ImageInfo | null): string {
+    return image && IMAGE_MIME_PATTERN.test(image.type)
+      ? `data:${image.type};base64,${image.body}`
+      : '';
+  }
+
   private _emitImagePair(section: HTMLElement) {
     const tr = this._createElement('tr');
 
@@ -95,18 +128,19 @@
     section: HTMLElement
   ) {
     const td = this._createElement('td', className);
-    if (image && IMAGE_MIME_PATTERN.test(image.type)) {
+    const src = this._getImageSrc(image);
+    if (image && src) {
       const imageEl = this._createElement('img') as HTMLImageElement;
       imageEl.onload = () => {
         image._height = imageEl.naturalHeight;
         image._width = imageEl.naturalWidth;
         this._updateImageLabel(section, className, image);
       };
-      imageEl.setAttribute('src', `data:${image.type};base64, ${image.body}`);
       imageEl.addEventListener('error', (e: Event) => {
         imageEl.remove();
         td.textContent = '[Image failed to load] ' + e.type;
       });
+      imageEl.setAttribute('src', src);
       td.appendChild(imageEl);
     }
     return td;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
index 657dfa2..f44e006 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -17,20 +17,21 @@
 
 import {GrDiffBuilder} from './gr-diff-builder';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
 import {DiffViewMode, Side} from '../../../constants/constants';
+import {DiffLayer} from '../../../types/types';
+import {RenderPreferences} from '../../../api/diff';
 
 export class GrDiffBuilderSideBySide extends GrDiffBuilder {
   constructor(
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
-    // TODO(TS): Replace any by a layer interface.
-    readonly layers: any[] = [],
-    useNewContextControls = false
+    readonly layers: DiffLayer[] = [],
+    renderPrefs?: RenderPreferences
   ) {
-    super(diff, prefs, outputEl, layers, useNewContextControls);
+    super(diff, prefs, outputEl, layers, renderPrefs);
   }
 
   _getMoveControlsConfig() {
@@ -50,7 +51,7 @@
     if (group.dueToRebase) {
       sectionEl.classList.add('dueToRebase');
     }
-    if (group.dueToMove) {
+    if (group.moveDetails) {
       sectionEl.classList.add('dueToMove');
       sectionEl.appendChild(this._buildMoveControls(group));
     }
@@ -82,12 +83,12 @@
     colgroup.appendChild(col);
 
     // Add left-side line number.
-    col = document.createElement('col');
+    col = this._createElement('col', 'left');
     col.setAttribute('width', width.toString());
     colgroup.appendChild(col);
 
     // Add left-side content.
-    colgroup.appendChild(document.createElement('col'));
+    colgroup.appendChild(this._createElement('col', 'left'));
 
     // Add right-side line number.
     col = document.createElement('col');
@@ -105,6 +106,7 @@
     row.classList.add('diff-row', 'side-by-side');
     row.setAttribute('left-type', leftLine.type);
     row.setAttribute('right-type', rightLine.type);
+    // TabIndex makes screen reader read a row when navigating with j/k
     row.tabIndex = -1;
 
     row.appendChild(this._createBlameCell(leftLine.beforeNumber));
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
index 2028b0c..e927fdf 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -17,19 +17,20 @@
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {GrDiffBuilder} from './gr-diff-builder';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffViewMode, Side} from '../../../constants/constants';
+import {DiffLayer} from '../../../types/types';
+import {RenderPreferences} from '../../../api/diff';
 
 export class GrDiffBuilderUnified extends GrDiffBuilder {
   constructor(
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
-    // TODO(TS): Replace any by a layer interface.
-    readonly layers: any[] = [],
-    useNewContextControls = false
+    readonly layers: DiffLayer[] = [],
+    renderPrefs?: RenderPreferences
   ) {
-    super(diff, prefs, outputEl, layers, useNewContextControls);
+    super(diff, prefs, outputEl, layers, renderPrefs);
   }
 
   _getMoveControlsConfig() {
@@ -49,7 +50,7 @@
     if (group.dueToRebase) {
       sectionEl.classList.add('dueToRebase');
     }
-    if (group.dueToMove) {
+    if (group.moveDetails) {
       sectionEl.classList.add('dueToMove');
       sectionEl.appendChild(this._buildMoveControls(group));
     }
@@ -104,6 +105,7 @@
   _createRow(line: GrDiffLine) {
     const row = this._createElement('tr', line.type);
     row.classList.add('diff-row', 'unified');
+    // TabIndex makes screen reader read a row when navigating with j/k
     row.tabIndex = -1;
     row.appendChild(this._createBlameCell(line.beforeNumber));
     let lineNumberEl = this._createLineEl(
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
index 07c6410..c08764e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
@@ -105,7 +105,7 @@
       lines[0].text = 'def hello_world():';
       lines[1].text = '  print "Hello World"';
       const group = new GrDiffGroup(GrDiffGroupType.DELTA, lines);
-      group.dueToMove = true;
+      group.moveDetails = {changed: false};
 
       const sectionEl = diffBuilder.buildSectionElement(group);
 
@@ -116,7 +116,7 @@
       assert.equal(rowEls.length, 3);
       assert.isTrue(moveControlsRow.classList.contains('movedOut'));
       assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveDescription'));
+      assert.isTrue(cells[2].classList.contains('moveLabel'));
       assert.equal(cells[2].textContent, 'Moved out');
     });
 
@@ -128,7 +128,7 @@
       lines[0].text = 'def hello_world():';
       lines[1].text = '  print "Hello World"';
       const group = new GrDiffGroup(GrDiffGroupType.DELTA, lines);
-      group.dueToMove = true;
+      group.moveDetails = {changed: false};
 
       const sectionEl = diffBuilder.buildSectionElement(group);
 
@@ -139,7 +139,7 @@
       assert.equal(rowEls.length, 3);
       assert.isTrue(moveControlsRow.classList.contains('movedIn'));
       assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveDescription'));
+      assert.isTrue(cells[2].classList.contains('moveLabel'));
       assert.equal(cells[2].textContent, 'Moved in');
     });
   });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index a3778ef..eb943f7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -14,22 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {
+  ContentLoadNeededEventDetail,
+  MovedLinkClickedEventDetail,
+  RenderPreferences,
+} from '../../../api/diff';
 import {getBaseUrl} from '../../../utils/url-util';
 import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
 import {
   GrDiffGroup,
-  GrDiffGroupRange,
   GrDiffGroupType,
   hideInContextControl,
-  rangeBySide,
 } from '../gr-diff/gr-diff-group';
-import {BlameInfo, DiffInfo, DiffPreferencesInfo} from '../../../types/common';
+import {BlameInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffViewMode, Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
+import {pluralize} from '../../../utils/string-util';
+import {fire} from '../../../utils/event-util';
 
 /**
  * In JS, unicode code points above 0xFFFF occupy two elements of a string.
- * For example '𐀏'.length is 2. An occurence of such a code point is called a
+ * For example '𐀏'.length is 2. An occurrence of such a code point is called a
  * surrogate pair.
  *
  * This regex segments a string along tabs ('\t') and surrogate pairs, since
@@ -57,16 +63,17 @@
   ALL = 'all',
 }
 
-export interface ContextEvent extends Event {
-  detail: {
-    groups: GrDiffGroup[];
-    section: HTMLElement;
-    numLines: number;
-  };
+export interface DiffContextExpandedEventDetail {
+  groups: GrDiffGroup[];
+  section: HTMLElement;
+  numLines: number;
 }
 
-export interface ContentLoadNeededEventDetail {
-  lineRange: GrDiffGroupRange;
+declare global {
+  interface HTMLElementEventMap {
+    'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
+    'content-load-needed': CustomEvent<ContentLoadNeededEventDetail>;
+  }
 }
 
 export abstract class GrDiffBuilder {
@@ -76,11 +83,13 @@
 
   private readonly _prefs: DiffPreferencesInfo;
 
+  private readonly _renderPrefs?: RenderPreferences;
+
   protected readonly _outputEl: HTMLElement;
 
   readonly groups: GrDiffGroup[];
 
-  private _blameInfo: BlameInfo[] | null;
+  private blameInfo: BlameInfo[] | null;
 
   private readonly _layerUpdateListener: (
     start: LineNumber,
@@ -93,7 +102,7 @@
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
     readonly layers: DiffLayer[] = [],
-    protected readonly useNewContextControls: boolean = false
+    renderPrefs?: RenderPreferences
   ) {
     this._diff = diff;
     this._numLinesLeft = this._diff.content
@@ -103,9 +112,10 @@
         }, 0)
       : 0;
     this._prefs = prefs;
+    this._renderPrefs = renderPrefs;
     this._outputEl = outputEl;
     this.groups = [];
-    this._blameInfo = null;
+    this.blameInfo = null;
 
     if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
       throw Error('Invalid tab size from preferences.');
@@ -179,9 +189,10 @@
       let groupStartLine = 0;
       let groupEndLine = 0;
       if (side) {
-        const range = rangeBySide(group.lineRange, side);
-        groupStartLine = range.start || 0;
-        groupEndLine = range.end || 0;
+        const range =
+          side === Side.LEFT ? group.lineRange.left : group.lineRange.right;
+        groupStartLine = range.start_line;
+        groupEndLine = range.end_line;
       }
 
       if (groupStartLine === 0) {
@@ -310,8 +321,9 @@
     contextGroups: GrDiffGroup[],
     viewMode: DiffViewMode
   ) {
-    const leftStart = contextGroups[0].lineRange.left.start!;
-    const leftEnd = contextGroups[contextGroups.length - 1].lineRange.left.end!;
+    const leftStart = contextGroups[0].lineRange.left.start_line;
+    const leftEnd =
+      contextGroups[contextGroups.length - 1].lineRange.left.end_line;
     const numLines = leftEnd - leftStart + 1;
 
     if (numLines === 0) console.error('context group without lines');
@@ -319,157 +331,70 @@
     const firstGroupIsSkipped = !!contextGroups[0].skip;
     const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;
 
-    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
     const showAbove = leftStart > 1 && !firstGroupIsSkipped;
     const showBelow = leftEnd < this._numLinesLeft && !lastGroupIsSkipped;
 
-    if (this.useNewContextControls) {
-      section.classList.add('newStyle');
-      if (showAbove) {
-        const paddingRow = this._createContextControlPaddingRow(viewMode);
-        paddingRow.classList.add('above');
-        section.appendChild(paddingRow);
-      }
-      section.appendChild(
-        this._createNewContextControlRow(
-          section,
-          contextGroups,
-          showAbove,
-          showBelow,
-          numLines
-        )
-      );
-      if (showBelow) {
-        const paddingRow = this._createContextControlPaddingRow(viewMode);
-        paddingRow.classList.add('below');
-        section.appendChild(paddingRow);
-      }
-    } else {
-      section.appendChild(
-        this._createOldContextControlRow(
-          section,
-          contextGroups,
-          viewMode,
-          showAbove && showPartialLinks,
-          showBelow && showPartialLinks,
-          numLines
-        )
-      );
+    if (showAbove) {
+      const paddingRow = this._createContextControlPaddingRow(viewMode);
+      paddingRow.classList.add('above');
+      section.appendChild(paddingRow);
     }
-  }
-
-  /**
-   * Creates old-style context controls: a single row of "+X above" and
-   * "+X below" buttons.
-   */
-  _createOldContextControlRow(
-    section: HTMLElement,
-    contextGroups: GrDiffGroup[],
-    viewMode: DiffViewMode,
-    showAbove: boolean,
-    showBelow: boolean,
-    numLines: number
-  ) {
-    const row = this._createElement('tr', GrDiffGroupType.CONTEXT_CONTROL);
-
-    row.classList.add('diff-row');
-    row.classList.add(
-      viewMode === DiffViewMode.SIDE_BY_SIDE ? 'side-by-side' : 'unified'
-    );
-
-    row.tabIndex = -1;
-    row.appendChild(this._createBlameCell(0));
-    row.appendChild(this._createElement('td', 'contextLineNum'));
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.appendChild(
-        this._createOldContextControlButtons(
-          section,
-          contextGroups,
-          showAbove,
-          showBelow,
-          numLines
-        )
-      );
-    }
-    row.appendChild(this._createElement('td', 'contextLineNum'));
-    row.appendChild(
-      this._createOldContextControlButtons(
+    section.appendChild(
+      this._createContextControlRow(
         section,
         contextGroups,
         showAbove,
         showBelow,
-        numLines
+        numLines,
+        viewMode
       )
     );
-
-    return row;
-  }
-
-  _createOldContextControlButtons(
-    section: HTMLElement,
-    contextGroups: GrDiffGroup[],
-    showAbove: boolean,
-    showBelow: boolean,
-    numLines: number
-  ): HTMLElement {
-    const td = this._createElement('td');
-
-    if (showAbove) {
-      td.appendChild(
-        this._createContextButton(
-          ContextButtonType.ABOVE,
-          section,
-          contextGroups,
-          numLines
-        )
-      );
-    }
-
-    td.appendChild(
-      this._createContextButton(
-        ContextButtonType.ALL,
-        section,
-        contextGroups,
-        numLines
-      )
-    );
-
     if (showBelow) {
-      td.appendChild(
-        this._createContextButton(
-          ContextButtonType.BELOW,
-          section,
-          contextGroups,
-          numLines
-        )
-      );
+      const paddingRow = this._createContextControlPaddingRow(viewMode);
+      paddingRow.classList.add('below');
+      section.appendChild(paddingRow);
     }
-
-    return td;
   }
 
   /**
-   * Creates new-style context controls: buttons extend from the gap created by
-   * this method up or down into the area of code that they affect.
+   * Creates context controls. Buttons extend from the gap created by this
+   * method up or down into the area of code that they affect.
    */
-  _createNewContextControlRow(
+  _createContextControlRow(
     section: HTMLElement,
     contextGroups: GrDiffGroup[],
     showAbove: boolean,
     showBelow: boolean,
-    numLines: number
+    numLines: number,
+    viewMode: DiffViewMode
   ): HTMLElement {
-    const row = this._createElement('tr', 'contextDivider');
-    if (!(showAbove && showBelow)) {
-      row.classList.add('collapsed');
+    const row = this._createElement('tr', 'dividerRow');
+    if (showAbove && !showBelow) {
+      row.classList.add('showAboveOnly');
+    } else if (!showAbove && showBelow) {
+      row.classList.add('showBelowOnly');
+    } else {
+      // Note that !showAbove && !showBelow also intentionally creates
+      // "showBoth". This means the file is completely collapsed, which is
+      // unusual, but at least happens in one test.
+      row.classList.add('showBoth');
     }
 
-    const element = this._createElement('td', 'dividerCell');
-    row.appendChild(element);
+    row.appendChild(this._createBlameCell(0));
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.appendChild(this._createElement('td'));
+    }
+
+    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');
-    element.appendChild(showAllContainer);
-
+    horizontalFlex.appendChild(showAllContainer);
     const showAllButton = this._createContextButton(
       ContextButtonType.ALL,
       section,
@@ -508,7 +433,7 @@
           )
         );
       }
-      element.appendChild(container);
+      horizontalFlex.appendChild(container);
     }
 
     return row;
@@ -550,9 +475,7 @@
   ) {
     const context = PARTIAL_CONTEXT_AMOUNT;
     const button = this._createElement('gr-button', 'showContext');
-    if (this.useNewContextControls) {
-      button.classList.add('contextControlButton');
-    }
+    button.classList.add('contextControlButton');
     button.setAttribute('link', 'true');
     button.setAttribute('no-uppercase', 'true');
 
@@ -560,17 +483,11 @@
     let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
     let requiresLoad = false;
     if (type === GrDiffBuilder.ContextButtonType.ALL) {
-      if (this.useNewContextControls) {
-        text = `+${numLines} common line`;
-      } else {
-        text = `Show ${numLines} common line`;
-        const icon = this._createElement('iron-icon', 'showContext');
-        icon.setAttribute('icon', 'gr-icons:unfold-more');
-        button.appendChild(icon);
-      }
-      if (numLines > 1) {
-        text += 's';
-      }
+      text = `+${pluralize(numLines, 'common line')}`;
+      button.setAttribute(
+        'aria-label',
+        `Show ${pluralize(numLines, 'common line')}`
+      );
       requiresLoad = contextGroups.find(c => !!c.skip) !== undefined;
       if (requiresLoad) {
         // Expanding content would require load of more data
@@ -579,53 +496,52 @@
       groups.push(...contextGroups);
     } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
       groups = hideInContextControl(contextGroups, context, numLines);
-      if (this.useNewContextControls) {
-        text = `+${context}`;
-        button.classList.add('aboveButton');
-      } else {
-        text = `+${context} above`;
-      }
+      text = `+${context}`;
+      button.classList.add('aboveButton');
+      button.setAttribute(
+        'aria-label',
+        `Show ${pluralize(context, 'line')} above`
+      );
     } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
       groups = hideInContextControl(contextGroups, 0, numLines - context);
-      if (this.useNewContextControls) {
-        text = `+${context}`;
-        button.classList.add('belowButton');
-      } else {
-        text = `+${context} below`;
-      }
+      text = `+${context}`;
+      button.classList.add('belowButton');
+      button.setAttribute(
+        'aria-label',
+        `Show ${pluralize(context, 'line')} below`
+      );
     }
     const textSpan = this._createElement('span', 'showContext');
     textSpan.textContent = text;
     button.appendChild(textSpan);
 
     if (requiresLoad) {
-      button.addEventListener('tap', e => {
+      button.addEventListener('click', e => {
         e.stopPropagation();
         const firstRange = groups[0].lineRange;
         const lastRange = groups[groups.length - 1].lineRange;
         const lineRange = {
-          left: {start: firstRange.left.start, end: lastRange.left.end},
-          right: {start: firstRange.right.start, end: lastRange.right.end},
+          left: {
+            start_line: firstRange.left.start_line,
+            end_line: lastRange.left.end_line,
+          },
+          right: {
+            start_line: firstRange.right.start_line,
+            end_line: lastRange.right.end_line,
+          },
         };
-        button.dispatchEvent(
-          new CustomEvent<ContentLoadNeededEventDetail>('content-load-needed', {
-            detail: {
-              lineRange,
-            },
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fire(button, 'content-load-needed', {
+          lineRange,
+        });
       });
     } else {
-      button.addEventListener('tap', e => {
-        const event = e as ContextEvent;
-        event.detail = {
+      button.addEventListener('click', e => {
+        e.stopPropagation();
+        fire(button, 'diff-context-expanded', {
           groups,
           section,
           numLines,
-        };
-        // Let it bubble up the DOM tree.
+        });
       });
     }
 
@@ -647,7 +563,12 @@
       td.classList.add('lineNum');
       td.dataset['value'] = number.toString();
 
-      if (this._prefs.show_file_comment_button === false && number === 'FILE') {
+      if (
+        ((this._prefs.show_file_comment_button === false ||
+          this._renderPrefs?.show_file_comment_button === false) &&
+          number === 'FILE') ||
+        number === 'LOST'
+      ) {
         return td;
       }
 
@@ -658,6 +579,9 @@
       button.classList.add(side);
       button.dataset['value'] = number.toString();
       button.textContent = number === 'FILE' ? 'File' : number.toString();
+      if (number === 'FILE') {
+        button.setAttribute('aria-label', 'Add file comment');
+      }
 
       // Add aria-labels for valid line numbers.
       // For unified diff, this method will be called with number set to 0 for
@@ -692,7 +616,7 @@
     }
     td.classList.add(line.type);
 
-    if (line.beforeNumber !== 'FILE') {
+    if (line.beforeNumber !== 'FILE' && line.beforeNumber !== 'LOST') {
       const lineLimit = !this._prefs.line_wrapping
         ? this._prefs.line_length
         : Infinity;
@@ -717,9 +641,8 @@
       }
 
       td.appendChild(contentText);
-    } else {
-      td.classList.add('file');
-    }
+    } else if (line.beforeNumber === 'FILE') td.classList.add('file');
+    else if (line.beforeNumber === 'LOST') td.classList.add('lost');
 
     return td;
   }
@@ -866,7 +789,7 @@
    * re-render its blame cell content.
    */
   setBlame(blame: BlameInfo[] | null) {
-    this._blameInfo = blame;
+    this.blameInfo = blame;
     if (!blame) return;
 
     // TODO(wyatta): make this loop asynchronous.
@@ -891,6 +814,53 @@
     }
   }
 
+  _createMovedLineAnchor(line: number, side: Side) {
+    const anchor = this._createElementWithText('a', `${line}`);
+
+    // href is not actually used but important for Screen Readers
+    anchor.setAttribute('href', `#${line}`);
+    anchor.addEventListener('click', e => {
+      e.preventDefault();
+      anchor.dispatchEvent(
+        new CustomEvent<MovedLinkClickedEventDetail>('moved-link-clicked', {
+          detail: {
+            lineNum: line,
+            side,
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+    });
+    return anchor;
+  }
+
+  _createElementWithText(tagName: string, textContent: string) {
+    const element = this._createElement(tagName);
+    element.textContent = textContent;
+    return element;
+  }
+
+  _createMoveDescriptionDiv(movedIn: boolean, group: GrDiffGroup) {
+    const div = this._createElement('div');
+    if (group.moveDetails?.range) {
+      const {changed, range} = group.moveDetails;
+      const otherSide = movedIn ? Side.LEFT : Side.RIGHT;
+      const andChangedLabel = changed ? 'and changed ' : '';
+      const direction = movedIn ? 'from' : 'to';
+      const textLabel = `Moved ${andChangedLabel}${direction} lines `;
+      div.appendChild(this._createElementWithText('span', textLabel));
+      div.appendChild(this._createMovedLineAnchor(range.start, otherSide));
+      div.appendChild(this._createElementWithText('span', ' - '));
+      div.appendChild(this._createMovedLineAnchor(range.end, otherSide));
+    } else {
+      div.appendChild(
+        this._createElementWithText('span', movedIn ? 'Moved in' : 'Moved out')
+      );
+    }
+    return div;
+  }
+
   _buildMoveControls(group: GrDiffGroup) {
     const movedIn = group.adds.length > 0;
     const {
@@ -900,24 +870,27 @@
     } = this._getMoveControlsConfig();
 
     let controlsClass;
-    let descriptionText;
     let descriptionIndex;
+    const descriptionTextDiv = this._createMoveDescriptionDiv(movedIn, group);
     if (movedIn) {
       controlsClass = 'movedIn';
       descriptionIndex = movedInIndex;
-      descriptionText = 'Moved in';
     } else {
       controlsClass = 'movedOut';
       descriptionIndex = movedOutIndex;
-      descriptionText = 'Moved out';
     }
-    const controls = document.createElement('tr');
+
+    const controls = this._createElement('tr', `moveControls ${controlsClass}`);
     const cells = [...Array(numberOfCells).keys()].map(() =>
-      document.createElement('td')
+      this._createElement('td')
     );
-    controls.classList.add('moveControls', controlsClass);
-    cells[descriptionIndex].classList.add('moveDescription');
-    cells[descriptionIndex].textContent = descriptionText;
+    const moveDescriptionDiv = this._createElement('div', 'moveDescription');
+    const icon = this._createElement('iron-icon');
+    icon.setAttribute('icon', 'gr-icons:move-item');
+    moveDescriptionDiv.appendChild(icon);
+    moveDescriptionDiv.appendChild(descriptionTextDiv);
+    cells[descriptionIndex].appendChild(moveDescriptionDiv);
+    cells[descriptionIndex].classList.add('moveLabel');
     cells.forEach(c => {
       controls.appendChild(c);
     });
@@ -941,11 +914,11 @@
    * @return The commit information.
    */
   _getBlameCommitForBaseLine(lineNum: LineNumber) {
-    if (!this._blameInfo) {
+    if (!this.blameInfo) {
       return null;
     }
 
-    for (const blameCommit of this._blameInfo) {
+    for (const blameCommit of this.blameInfo) {
       for (const range of blameCommit.ranges) {
         if (range.start <= lineNum && range.end >= lineNum) {
           return blameCommit;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
index e7fb30e..9b7c25a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -20,14 +20,14 @@
   AbortStop,
   CursorMoveResult,
   GrCursorManager,
+  Stop,
   isTargetable,
 } from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-cursor_html';
+import {DiffViewMode} from '../../../api/diff';
 import {ScrollMode, Side} from '../../../constants/constants';
 import {customElement, property, observe} from '@polymer/decorators';
 import {GrDiffLineType} from '../gr-diff/gr-diff-line';
@@ -35,11 +35,9 @@
 import {PolymerDomWrapper} from '../../../types/types';
 import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {GrDiff} from '../gr-diff/gr-diff';
-
-const DiffViewMode = {
-  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-  UNIFIED: 'UNIFIED_DIFF',
-};
+import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {Subscription} from 'rxjs';
+import {toggleClass} from '../../../utils/dom-util';
 
 type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
 
@@ -50,30 +48,18 @@
 const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
 
 export interface GrDiffCursor {
-  $: {
-    cursorManager: GrCursorManager;
-  };
+  $: {};
 }
 
 @customElement('gr-diff-cursor')
-export class GrDiffCursor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffCursor extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
 
-  private _boundHandleWindowScroll: () => void;
+  private preventAutoScrollOnManualScroll = false;
 
-  private _boundHandleDiffRenderStart: () => void;
-
-  private _boundHandleDiffRenderContent: () => void;
-
-  private _boundHandleDiffLineSelected: (e: Event) => void;
-
-  private _preventAutoScrollOnManualScroll = false;
-
-  private lastDisplayedNavigateToNextFileToast: number | null = null;
+  private lastDisplayedNavigateToFileToast: Map<string, number> = new Map();
 
   @property({type: String})
   side = Side.RIGHT;
@@ -95,27 +81,16 @@
   @property({type: Number})
   initialLineNumber: number | null = null;
 
-  /**
-   * The scroll behavior for the cursor. Values are 'never' and
-   * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
-   * the viewport.
-   */
-  @property({type: String})
-  _scrollMode = ScrollMode.KEEP_VISIBLE;
-
-  @property({type: Boolean})
-  _focusOnMove = true;
-
   @property({type: Boolean})
   _listeningForScroll = false;
 
+  private cursorManager = new GrCursorManager();
+
   constructor() {
     super();
-    this._boundHandleWindowScroll = () => this._handleWindowScroll();
-    this._boundHandleDiffRenderStart = () => this._handleDiffRenderStart();
-    this._boundHandleDiffRenderContent = () => this._handleDiffRenderContent();
-    this._boundHandleDiffLineSelected = (e: Event) =>
-      this._handleDiffLineSelected(e);
+    this.cursorManager.cursorTargetClass = 'target-row';
+    this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
+    this.cursorManager.focusOnMove = true;
   }
 
   /** @override */
@@ -141,17 +116,34 @@
     });
   }
 
+  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() {
-    super.disconnectedCallback();
+    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.
+  isAtStart() {
+    return this.cursorManager.isAtStart();
+  }
+
+  // Don't remove - used by clients embedding gr-diff outside of Gerrit.
+  isAtEnd() {
+    return this.cursorManager.isAtEnd();
   }
 
   moveLeft() {
@@ -170,106 +162,115 @@
 
   moveDown() {
     if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.cursorManager.next({
+      return this.cursorManager.next({
         filter: (row: Element) => this._rowHasSide(row),
       });
     } else {
-      this.$.cursorManager.next();
+      return this.cursorManager.next();
     }
   }
 
   moveUp() {
     if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.cursorManager.previous({
+      return this.cursorManager.previous({
         filter: (row: Element) => this._rowHasSide(row),
       });
     } else {
-      this.$.cursorManager.previous();
+      return this.cursorManager.previous();
     }
   }
 
   moveToVisibleArea() {
     if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.cursorManager.moveToVisibleArea((row: Element) =>
+      this.cursorManager.moveToVisibleArea((row: Element) =>
         this._rowHasSide(row)
       );
     } else {
-      this.$.cursorManager.moveToVisibleArea();
+      this.cursorManager.moveToVisibleArea();
     }
   }
 
-  moveToNextChunk(clipToTop?: boolean, navigateToNextFile?: boolean) {
-    const result = this.$.cursorManager.next({
+  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 {
+    const result = this.cursorManager.next({
       filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
       getTargetHeight: target =>
         (target?.parentNode as HTMLElement)?.scrollHeight || 0,
       clipToTop,
     });
-    /*
-     * If user presses n on the last diff chunk, show a toast informing user
-     * that pressing n again will navigate them to next unreviewed file.
-     * If click happens within the time limit, then navigate to next file
-     */
     if (
       navigateToNextFile &&
       result === CursorMoveResult.CLIPPED &&
-      this.$.cursorManager.isAtEnd()
+      this.isAtEnd()
     ) {
-      if (
-        this.lastDisplayedNavigateToNextFileToast &&
-        Date.now() - this.lastDisplayedNavigateToNextFileToast <=
-          NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS
-      ) {
-        // reset for next file
-        this.lastDisplayedNavigateToNextFileToast = null;
-        this.dispatchEvent(
-          new CustomEvent('navigate-to-next-unreviewed-file', {
-            composed: true,
-            bubbles: true,
-          })
-        );
-      }
-      this.lastDisplayedNavigateToNextFileToast = Date.now();
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Press n again to navigate to next unreviewed file',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      this.showToastAndFireEvent('next', 'n');
     }
 
     this._fixSide();
+    return result;
   }
 
-  moveToPreviousChunk() {
-    this.$.cursorManager.previous({
+  moveToPreviousChunk(navigateToPreviousFile?: boolean): 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() {
-    this.$.cursorManager.next({
+  moveToNextCommentThread(): CursorMoveResult | undefined {
+    if (this.isAtEnd()) {
+      fireEvent(this, 'navigate-to-next-file-with-comments');
+      return;
+    }
+    const result = this.cursorManager.next({
       filter: (row: HTMLElement) => this._rowHasThread(row),
     });
     this._fixSide();
+    return result;
   }
 
-  moveToPreviousCommentThread() {
-    this.$.cursorManager.previous({
+  moveToPreviousCommentThread(): CursorMoveResult {
+    const result = this.cursorManager.previous({
       filter: (row: HTMLElement) => this._rowHasThread(row),
     });
     this._fixSide();
+    return result;
   }
 
   moveToLineNumber(number: number, side: Side, path?: string) {
     const row = this._findRowByNumberAndFile(number, side, path);
     if (row) {
       this.side = side;
-      this.$.cursorManager.setCursor(row);
+      this.cursorManager.setCursor(row);
     }
   }
 
@@ -301,13 +302,21 @@
   }
 
   moveToFirstChunk() {
-    this.$.cursorManager.moveToStart();
-    this.moveToNextChunk(true);
+    this.cursorManager.moveToStart();
+    if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
+      this.moveToNextChunk(true);
+    } else {
+      this._fixSide();
+    }
   }
 
   moveToLastChunk() {
-    this.$.cursorManager.moveToEnd();
-    this.moveToPreviousChunk();
+    this.cursorManager.moveToEnd();
+    if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
+      this.moveToPreviousChunk();
+    } else {
+      this._fixSide();
+    }
   }
 
   /**
@@ -322,7 +331,7 @@
   reInitCursor() {
     if (!this.diffRow) {
       // does not scroll during init unless requested
-      this._scrollMode = this.initialLineNumber
+      this.cursorManager.scrollMode = this.initialLineNumber
         ? ScrollMode.KEEP_VISIBLE
         : ScrollMode.NEVER;
       if (this.initialLineNumber) {
@@ -336,16 +345,16 @@
   }
 
   reInit() {
-    this._scrollMode = ScrollMode.KEEP_VISIBLE;
+    this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
   }
 
-  _handleWindowScroll() {
-    if (this._preventAutoScrollOnManualScroll) {
-      this._scrollMode = ScrollMode.NEVER;
-      this._focusOnMove = false;
-      this._preventAutoScrollOnManualScroll = false;
+  private _boundHandleWindowScroll = () => {
+    if (this.preventAutoScrollOnManualScroll) {
+      this.cursorManager.scrollMode = ScrollMode.NEVER;
+      this.cursorManager.focusOnMove = false;
+      this.preventAutoScrollOnManualScroll = false;
     }
-  }
+  };
 
   reInitAndUpdateStops() {
     this.reInit();
@@ -357,25 +366,29 @@
     this.reInitCursor();
   }
 
-  _handleDiffRenderStart() {
-    this._preventAutoScrollOnManualScroll = true;
-  }
+  private boundHandleDiffLoadingChanged = () => {
+    this._updateStops();
+  };
 
-  _handleDiffRenderContent() {
+  private _boundHandleDiffRenderStart = () => {
+    this.preventAutoScrollOnManualScroll = true;
+  };
+
+  private _boundHandleDiffRenderContent = () => {
     this._updateStops();
     // When done rendering, turn focus on move and automatic scrolling back on
-    this._focusOnMove = true;
-    this._preventAutoScrollOnManualScroll = false;
-  }
+    this.cursorManager.focusOnMove = true;
+    this.preventAutoScrollOnManualScroll = false;
+  };
 
-  _handleDiffLineSelected(event: Event) {
+  private _boundHandleDiffLineSelected = (event: Event) => {
     const customEvent = event as CustomEvent;
     this.moveToLineNumber(
       customEvent.detail.number,
       customEvent.detail.side,
       customEvent.detail.path
     );
-  }
+  };
 
   createCommentInPlace() {
     const diffWithRangeSelected = this.diffs.find(diff =>
@@ -398,7 +411,6 @@
    * {leftSide: true, number: 321} for line 321 of the base patch.
    * Returns null if an address is not available.
    *
-   * @return
    */
   getAddress() {
     if (!this.diffRow) {
@@ -500,8 +512,8 @@
     if (!this.diffRow) {
       return;
     }
-    this.toggleClass(LEFT_SIDE_CLASS, this.side === Side.LEFT, this.diffRow);
-    this.toggleClass(RIGHT_SIDE_CLASS, this.side === Side.RIGHT, this.diffRow);
+    toggleClass(this.diffRow, LEFT_SIDE_CLASS, this.side === Side.LEFT);
+    toggleClass(this.diffRow, RIGHT_SIDE_CLASS, this.side === Side.RIGHT);
   }
 
   _isActionType(type: GrDiffRowType) {
@@ -524,8 +536,8 @@
   }
 
   _updateStops() {
-    this.$.cursorManager.stops = this.diffs.reduce(
-      (stops: HTMLElement[], diff) => stops.concat(diff.getCursorStops()),
+    this.cursorManager.stops = this.diffs.reduce(
+      (stops: Stop[], diff) => stops.concat(diff.getCursorStops()),
       []
     );
   }
@@ -555,6 +567,10 @@
       // might be the same.
       for (i = 0; i < splice?.removed.length; i++) {
         splice.removed[i].removeEventListener(
+          'loading-changed',
+          this.boundHandleDiffLoadingChanged
+        );
+        splice.removed[i].removeEventListener(
           'render-start',
           this._boundHandleDiffRenderStart
         );
@@ -570,6 +586,10 @@
 
       for (i = splice.index; i < splice.index + splice.addedCount; i++) {
         this.diffs[i].addEventListener(
+          'loading-changed',
+          this.boundHandleDiffLoadingChanged
+        );
+        this.diffs[i].addEventListener(
           'render-start',
           this._boundHandleDiffRenderStart
         );
@@ -595,7 +615,7 @@
       const diff = this.diffs.filter(diff => diff.path === path)[0];
       stops = diff.getCursorStops();
     } else {
-      stops = this.$.cursorManager.stops;
+      stops = this.cursorManager.stops;
     }
     // Sadly needed for type narrowing to understand that the result is always
     // targetable.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts
index 1539a22..1489006 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts
@@ -16,12 +16,4 @@
  */
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-cursor-manager
-    id="cursorManager"
-    scroll-mode="[[_scrollMode]]"
-    cursor-target-class="target-row"
-    focus-on-move="[[_focusOnMove]]"
-    target="{{diffRow}}"
-  ></gr-cursor-manager>
-`;
+export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
index 8e95f3d..75439c8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -18,14 +18,14 @@
 import '../../../test/common-test-setup-karma.js';
 import '../gr-diff/gr-diff.js';
 import './gr-diff-cursor.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {listenOnce} from '../../../test/test-utils.js';
+import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
+import {createDefaultDiffPrefs} from '../../../constants/constants.js';
 
 const basicFixture = fixtureFromTemplate(html`
   <gr-diff></gr-diff>
   <gr-diff-cursor></gr-diff-cursor>
-  <gr-rest-api-interface></gr-rest-api-interface>
 `);
 
 const emptyFixture = fixtureFromElement('div');
@@ -39,7 +39,6 @@
     const fixtureElems = basicFixture.instantiate();
     diffElement = fixtureElems[0];
     cursorElement = fixtureElems[1];
-    const restAPI = fixtureElems[2];
 
     // Register the diff with the cursor.
     cursorElement.push('diffs', diffElement);
@@ -51,6 +50,7 @@
       right: [],
       meta: {patchRange: undefined},
     };
+    diffElement.path = 'some/path.ts';
     const setupDone = () => {
       cursorElement._updateStops();
       cursorElement.moveToFirstChunk();
@@ -60,10 +60,8 @@
     diffElement.addEventListener('render', setupDone);
 
     diff = getMockDiffResponse();
-    restAPI.getDiffPreferences().then(prefs => {
-      diffElement.prefs = prefs;
-      diffElement.diff = diff;
-    });
+    diffElement.prefs = createDefaultDiffPrefs();
+    diffElement.diff = diff;
   });
 
   test('diff cursor functionality (side-by-side)', () => {
@@ -85,39 +83,130 @@
     assert.equal(cursorElement.diffRow, firstDeltaRow);
   });
 
-  test('moveToLastChunk', () => {
+  test('moveToFirstChunk', async () => {
+    const diff = {
+      meta_a: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      meta_b: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+        'index b2adcf4..554ae49 100644',
+        '--- a/lorem-ipsum.txt',
+        '+++ b/lorem-ipsum.txt',
+      ],
+      content: [
+        {b: ['new line 1']},
+        {ab: ['unchanged line']},
+        {a: ['old line 2']},
+        {ab: ['more unchanged lines']},
+      ],
+    };
+
+    diffElement.diff = diff;
+    // The file comment button, if present, is a cursor stop. Ensure
+    // moveToFirstChunk() works correctly even if the button is not shown.
+    diffElement.prefs.show_file_comment_button = false;
+    await flush();
+    cursorElement._updateStops();
+
     const chunks = Array.from(diffElement.root.querySelectorAll(
         '.section.delta'));
-    assert.isAbove(chunks.length, 1);
+    assert.equal(chunks.length, 2);
+
+    // Verify it works on fresh diff.
+    cursorElement.moveToFirstChunk();
     assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
+    assert.equal(cursorElement.side, 'right');
 
+    // Verify it works from other cursor positions.
+    cursorElement.moveToNextChunk();
+    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 1);
+    assert.equal(cursorElement.side, 'left');
+    cursorElement.moveToFirstChunk();
+    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
+    assert.equal(cursorElement.side, 'right');
+  });
+
+  test('moveToLastChunk', async () => {
+    const diff = {
+      meta_a: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      meta_b: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+        'index b2adcf4..554ae49 100644',
+        '--- a/lorem-ipsum.txt',
+        '+++ b/lorem-ipsum.txt',
+      ],
+      content: [
+        {ab: ['unchanged line']},
+        {a: ['old line 2']},
+        {ab: ['more unchanged lines']},
+        {b: ['new line 3']},
+      ],
+    };
+
+    diffElement.diff = diff;
+    await flush();
+    cursorElement._updateStops();
+
+    const chunks = Array.from(diffElement.root.querySelectorAll(
+        '.section.delta'));
+    assert.equal(chunks.length, 2);
+
+    // Verify it works on fresh diff.
     cursorElement.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 1);
+    assert.equal(cursorElement.side, 'right');
 
-    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement),
-        chunks.length - 1);
+    // Verify it works from other cursor positions.
+    cursorElement.moveToPreviousChunk();
+    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
+    assert.equal(cursorElement.side, 'left');
+    cursorElement.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 1);
+    assert.equal(cursorElement.side, 'right');
   });
 
   test('cursor scroll behavior', () => {
-    assert.equal(cursorElement._scrollMode, 'keep-visible');
+    assert.equal(cursorElement.cursorManager.scrollMode, 'keep-visible');
 
-    cursorElement._handleDiffRenderStart();
-    assert.isTrue(cursorElement._focusOnMove);
+    diffElement.dispatchEvent(new Event('render-start'));
+    assert.isTrue(cursorElement.cursorManager.focusOnMove);
 
-    cursorElement._handleWindowScroll();
-    assert.equal(cursorElement._scrollMode, 'never');
-    assert.isFalse(cursorElement._focusOnMove);
+    window.dispatchEvent(new Event('scroll'));
+    assert.equal(cursorElement.cursorManager.scrollMode, 'never');
+    assert.isFalse(cursorElement.cursorManager.focusOnMove);
 
-    cursorElement._handleDiffRenderContent();
-    assert.isTrue(cursorElement._focusOnMove);
+    diffElement.dispatchEvent(new Event('render-content'));
+    assert.isTrue(cursorElement.cursorManager.focusOnMove);
 
     cursorElement.reInitCursor();
-    assert.equal(cursorElement._scrollMode, 'keep-visible');
+    assert.equal(cursorElement.cursorManager.scrollMode, 'keep-visible');
   });
 
   test('moves to selected line', () => {
     const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
 
-    cursorElement._handleDiffLineSelected(
+    diffElement.dispatchEvent(
         new CustomEvent('line-selected', {
           detail: {number: '123', side: 'right', path: 'some/file'},
         }));
@@ -177,7 +266,7 @@
     // to the right side.
     assert.equal(cursorElement.side, 'right');
     assert.equal(cursorElement.diffRow, firstDeltaRow);
-    const firstIndex = cursorElement.$.cursorManager.index;
+    const firstIndex = cursorElement.cursorManager.index;
 
     // Move the side to the left. Because this delta only has a right side, we
     // should be moved up to the previous line where there is content on the
@@ -186,7 +275,7 @@
 
     assert.equal(cursorElement.side, 'left');
     assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-    assert.equal(cursorElement.$.cursorManager.index, firstIndex - 1);
+    assert.equal(cursorElement.cursorManager.index, firstIndex - 1);
     assert.equal(cursorElement.diffRow.parentElement,
         firstDeltaSection.previousSibling);
 
@@ -196,7 +285,7 @@
 
     assert.equal(cursorElement.side, 'left');
     assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-    assert.isTrue(cursorElement.$.cursorManager.index > firstIndex);
+    assert.isTrue(cursorElement.cursorManager.index > firstIndex);
     assert.equal(cursorElement.diffRow.parentElement,
         firstDeltaSection.nextSibling);
   });
@@ -225,7 +314,7 @@
     assert.equal(cursorElement.side, 'left');
   });
 
-  suite('moved chunks (dueToMove=true)', () => {
+  suite('moved chunks without line range)', () => {
     setup(done => {
       const renderHandler = function() {
         diffElement.removeEventListener('render', renderHandler);
@@ -245,7 +334,7 @@
             'Sagittis tincidunt torquent, tempor nunc amet.',
             'At rhoncus id.',
           ],
-          due_to_move: true,
+          move_details: {changed: false},
         },
         {
           ab: [
@@ -258,7 +347,7 @@
             'Sagittis tincidunt torquent, tempor nunc amet.',
             'At rhoncus id.',
           ],
-          due_to_move: true,
+          move_details: {changed: false},
         },
         {
           ab: [
@@ -268,33 +357,96 @@
       ]};
     });
 
-    test('chunk skip functionality', () => {
-      const chunks = diffElement.root.querySelectorAll(
-          '.section.delta');
-      const indexOfChunk = function(chunk) {
-        return Array.prototype.indexOf.call(chunks, chunk);
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = diffElement.root
+          .querySelectorAll('.dueToMove .moveControls');
+      assert.equal(movedIn.textContent, 'Moved in');
+      assert.equal(movedOut.textContent, 'Moved out');
+    });
+  });
+
+  suite('moved chunks (moveDetails)', () => {
+    setup(done => {
+      const renderHandler = function() {
+        diffElement.removeEventListener('render', renderHandler);
+        cursorElement.reInitCursor();
+        done();
       };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.diff = {...diff, content: [
+        {
+          ab: [
+            'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ',
+          ],
+        },
+        {
+          b: [
+            'Nullam neque, ligula ac, id blandit.',
+            'Sagittis tincidunt torquent, tempor nunc amet.',
+            'At rhoncus id.',
+          ],
+          move_details: {changed: false, range: {start: 4, end: 6}},
+        },
+        {
+          ab: [
+            'Sem nascetur, erat ut, non in.',
+          ],
+        },
+        {
+          a: [
+            'Nullam neque, ligula ac, id blandit.',
+            'Sagittis tincidunt torquent, tempor nunc amet.',
+            'At rhoncus id.',
+          ],
+          move_details: {changed: false, range: {start: 2, end: 4}},
+        },
+        {
+          ab: [
+            'Arcu eget, rhoncus amet cursus, ipsum elementum.',
+          ],
+        },
+      ]};
+    });
 
-      // We should be initialized to the first chunk (b)
-      let currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
-      assert.equal(currentIndex, 0);
-      assert.equal(cursorElement.side, 'right');
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = diffElement.root
+          .querySelectorAll('.dueToMove .moveControls');
+      assert.equal(movedIn.textContent, 'Moved from lines 4 - 6');
+      assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
+    });
 
-      // Move to the next chunk.
-      cursorElement.moveToNextChunk();
+    test('startLineAnchor of movedIn chunk fires events', done => {
+      const [movedIn] = diffElement.root
+          .querySelectorAll('.dueToMove .moveControls');
+      const [startLineAnchor] = movedIn.querySelectorAll('a');
 
-      // Since the next chunk only has content on the left side (a). we should have been
-      // automatically moved over.
-      const previousIndex = currentIndex;
-      currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
-      assert.equal(currentIndex, previousIndex + 1);
-      assert.equal(cursorElement.side, 'left');
+      const onMovedLinkClicked = e => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: 'left'});
+        done();
+      };
+      assert.equal(startLineAnchor.textContent, '4');
+      startLineAnchor
+          .addEventListener('moved-link-clicked', onMovedLinkClicked);
+      MockInteractions.click(startLineAnchor);
+    });
+
+    test('endLineAnchor of movedOut fires events', done => {
+      const [, movedOut] = diffElement.root
+          .querySelectorAll('.dueToMove .moveControls');
+      const [, endLineAnchor] = movedOut.querySelectorAll('a');
+
+      const onMovedLinkClicked = e => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: 'right'});
+        done();
+      };
+      assert.equal(endLineAnchor.textContent, '4');
+      endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
+      MockInteractions.click(endLineAnchor);
     });
   });
 
   test('navigate to next unreviewed file via moveToNextChunk', () => {
-    const cursorManager =
-        cursorElement.shadowRoot.querySelector('#cursorManager');
+    const cursorManager = cursorElement.cursorManager;
     cursorManager.index = cursorManager.stops.length - 1;
     const dispatchEventStub = sinon.stub(cursorElement, 'dispatchEvent');
     cursorElement.moveToNextChunk(/* opt_clipToTop = */false,
@@ -312,8 +464,9 @@
     let scrollBehaviorDuringMove;
     const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
     const moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk')
-        .callsFake(
-            () => { scrollBehaviorDuringMove = cursorElement._scrollMode; });
+        .callsFake(() => {
+          scrollBehaviorDuringMove = cursorElement.cursorManager.scrollMode;
+        });
 
     function renderHandler() {
       diffElement.removeEventListener('render', renderHandler);
@@ -321,7 +474,7 @@
       assert.isFalse(moveToNumStub.called);
       assert.isTrue(moveToChunkStub.called);
       assert.equal(scrollBehaviorDuringMove, 'never');
-      assert.equal(cursorElement._scrollMode, 'keep-visible');
+      assert.equal(cursorElement.cursorManager.scrollMode, 'keep-visible');
       done();
     }
     diffElement.addEventListener('render', renderHandler);
@@ -331,8 +484,9 @@
   test('initialLineNumber provided', done => {
     let scrollBehaviorDuringMove;
     const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber')
-        .callsFake(
-            () => { scrollBehaviorDuringMove = cursorElement._scrollMode; });
+        .callsFake(() => {
+          scrollBehaviorDuringMove = cursorElement.cursorManager.scrollMode;
+        });
     const moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk');
     function renderHandler() {
       diffElement.removeEventListener('render', renderHandler);
@@ -342,7 +496,7 @@
       assert.equal(moveToNumStub.lastCall.args[0], 10);
       assert.equal(moveToNumStub.lastCall.args[1], 'right');
       assert.equal(scrollBehaviorDuringMove, 'keep-visible');
-      assert.equal(cursorElement._scrollMode, 'keep-visible');
+      assert.equal(cursorElement.cursorManager.scrollMode, 'keep-visible');
       done();
     }
     diffElement.addEventListener('render', renderHandler);
@@ -369,10 +523,9 @@
     test('adds new draft for selected line on the left', done => {
       cursorElement.moveToLineNumber(2, 'left');
       diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side, patchNum} = e.detail;
+        const {lineNum, range, side} = e.detail;
         assert.equal(lineNum, 2);
         assert.equal(range, undefined);
-        assert.equal(patchNum, 1);
         assert.equal(side, 'left');
         done();
       });
@@ -382,17 +535,16 @@
     test('adds draft for selected line on the right', done => {
       cursorElement.moveToLineNumber(4, 'right');
       diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side, patchNum} = e.detail;
+        const {lineNum, range, side} = e.detail;
         assert.equal(lineNum, 4);
         assert.equal(range, undefined);
-        assert.equal(patchNum, 2);
         assert.equal(side, 'right');
         done();
       });
       cursorElement.createCommentInPlace();
     });
 
-    test('createCommentInPlace creates comment for range if selected', done => {
+    test('creates comment for range if selected', done => {
       const someRange = {
         start_line: 2,
         start_character: 3,
@@ -404,17 +556,16 @@
         range: someRange,
       };
       diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side, patchNum} = e.detail;
+        const {lineNum, range, side} = e.detail;
         assert.equal(lineNum, 6);
         assert.equal(range, someRange);
-        assert.equal(patchNum, 2);
         assert.equal(side, 'right');
         done();
       });
       cursorElement.createCommentInPlace();
     });
 
-    test('createCommentInPlace ignores call if nothing is selected', () => {
+    test('ignores call if nothing is selected', () => {
       const createRangeCommentStub = sinon.stub(diffElement,
           'createRangeComment');
       const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
@@ -450,13 +601,13 @@
         {leftSide: true, number: 10});
 
     // Should be null if there is no selection.
-    cursorElement.$.cursorManager.unsetCursor();
+    cursorElement.cursorManager.unsetCursor();
     assert.isNotOk(cursorElement.getAddress());
   });
 
   test('_findRowByNumberAndFile', () => {
     // Get the first ab row after the first chunk.
-    const row = diffElement.root.querySelectorAll('tr')[8];
+    const row = diffElement.root.querySelectorAll('tr')[9];
 
     // It should be line 8 on the right, but line 5 on the left.
     assert.equal(cursorElement._findRowByNumberAndFile(8, 'right'), row);
@@ -473,6 +624,12 @@
     });
   });
 
+  test('updates stops when loading changes', () => {
+    sinon.spy(cursorElement, '_updateStops');
+    diffElement.dispatchEvent(new Event('loading-changed'));
+    assert.isTrue(cursorElement._updateStops.called);
+  });
+
   suite('gr-diff-cursor event tests', () => {
     let someEmptyDiv;
 
@@ -490,5 +647,74 @@
       someEmptyDiv.appendChild(cursorElement);
     });
   });
+
+  suite('multi diff', () => {
+    const multiDiffFixture = fixtureFromTemplate(html`
+      <gr-diff></gr-diff>
+      <gr-diff></gr-diff>
+      <gr-diff></gr-diff>
+      <gr-diff-cursor></gr-diff-cursor>
+    `);
+
+    let diffElements;
+
+    setup(() => {
+      const fixtureElems = multiDiffFixture.instantiate();
+      diffElements = fixtureElems.slice(0, 3);
+      cursorElement = fixtureElems[3];
+
+      // Register the diff with the cursor.
+      cursorElement.push('diffs', ...diffElements);
+
+      for (const el of diffElements) {
+        el.prefs = createDefaultDiffPrefs();
+      }
+    });
+
+    function getTargetDiffIndex() {
+      // Mocha has a bug where when `assert.equals` fails, it will try to
+      // JSON.stringify the operands, which fails when they are cyclic structures
+      // like GrDiffElement. The failure is difficult to attribute to a specific
+      // assertion because of the async nature assertion errors are handled and
+      // can cause the test simply timing out, causing a lot of debugging headache.
+      // Working with indices circumvents the problem.
+      return diffElements.indexOf(cursorElement.getTargetDiffElement());
+    }
+
+    test('do not skip loading diffs', async () => {
+      const diffRenderedPromises =
+          diffElements.map(diffEl => listenOnce(diffEl, 'render'));
+
+      diffElements[0].diff = getMockDiffResponse();
+      diffElements[2].diff = getMockDiffResponse();
+      await Promise.all([diffRenderedPromises[0], diffRenderedPromises[2]]);
+
+      const lastLine = diffElements[0].diff.meta_b.lines;
+
+      // Goto second last line of the first diff
+      cursorElement.moveToLineNumber(lastLine - 1, 'right');
+      assert.equal(
+          cursorElement.getTargetLineElement().textContent, lastLine - 1);
+
+      // Can move down until we reach the loading file
+      cursorElement.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(cursorElement.getTargetLineElement().textContent, lastLine);
+
+      // Cannot move down while still loading the diff we would switch to
+      cursorElement.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(cursorElement.getTargetLineElement().textContent, lastLine);
+
+      // Diff 1 finishing to load
+      diffElements[1].diff = getMockDiffResponse();
+      await diffRenderedPromises[1];
+
+      // Now we can go down
+      cursorElement.moveDown();
+      assert.equal(getTargetDiffIndex(), 1);
+      assert.equal(cursorElement.getTargetLineElement().textContent, 'File');
+    });
+  });
 });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
index 80eb0df..6a6e99e 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
@@ -17,8 +17,6 @@
 import '../../../styles/shared-styles';
 import '../gr-selection-action-box/gr-selection-action-box';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-highlight_html';
 import {GrAnnotation} from './gr-annotation';
@@ -30,6 +28,8 @@
 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 {debounce, DelayedTask} from '../../../utils/async-util';
 
 interface SidedRange {
   side: Side;
@@ -54,9 +54,7 @@
 }
 
 @customElement('gr-diff-highlight')
-export class GrDiffHighlight extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffHighlight extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -73,9 +71,10 @@
   @property({type: Object, notify: true})
   selectedRange?: SidedRange;
 
-  /** @override */
-  created() {
-    super.created();
+  private selectionChangeTask?: DelayedTask;
+
+  constructor() {
+    super();
     this.addEventListener('comment-thread-mouseleave', e =>
       this._handleCommentThreadMouseleave(e)
     );
@@ -87,6 +86,12 @@
     );
   }
 
+  /** @override */
+  disconnectedCallback() {
+    this.selectionChangeTask?.cancel();
+    super.disconnectedCallback();
+  }
+
   get diffBuilder() {
     if (!this._cachedDiffBuilder) {
       this._cachedDiffBuilder = this.querySelector(
@@ -124,8 +129,8 @@
     // quick 'c' press after the selection change. If you wait less than 10
     // ms, then you will have about 50 _handleSelection calls when doing a
     // simple drag for select.
-    this.debounce(
-      'selectionChange',
+    this.selectionChangeTask = debounce(
+      this.selectionChangeTask,
       () => this._handleSelection(selection, isMouseUp),
       10
     );
@@ -164,17 +169,35 @@
           `.range.${strToClassName(threadEl.rootId)}`
         );
         rangeNodes.forEach(rangeNode => {
-          rangeNode.classList.add('rangeHighlight');
-          rangeNode.classList.remove('range');
+          rangeNode.classList.add('rangeHoverHighlight');
         });
+        const hintNode = threadEl.parentElement?.querySelector(
+          `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
+        );
+        if (hintNode) {
+          hintNode.shadowRoot
+            ?.querySelectorAll('.rangeHighlight')
+            .forEach(highlightNode =>
+              highlightNode.classList.add('rangeHoverHighlight')
+            );
+        }
       } else {
         const rangeNodes = curNode.querySelectorAll(
-          `.rangeHighlight.${strToClassName(threadEl.rootId)}`
+          `.rangeHoverHighlight.${strToClassName(threadEl.rootId)}`
         );
         rangeNodes.forEach(rangeNode => {
-          rangeNode.classList.remove('rangeHighlight');
-          rangeNode.classList.add('range');
+          rangeNode.classList.remove('rangeHoverHighlight');
         });
+        const hintNode = threadEl.parentElement?.querySelector(
+          `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
+        );
+        if (hintNode) {
+          hintNode.shadowRoot
+            ?.querySelectorAll('.rangeHoverHighlight')
+            .forEach(highlightNode =>
+              highlightNode.classList.remove('rangeHoverHighlight')
+            );
+        }
       }
     }
   }
@@ -202,13 +225,9 @@
   }
 
   _indexForThreadEl(threadEl: HTMLElement) {
-    const side = threadEl.getAttribute('comment-side') as Side;
-    const rangeString = threadEl.getAttribute('range');
-    if (!rangeString) return undefined;
-    const range = JSON.parse(rangeString) as CommentRange;
-
-    if (!range) return undefined;
-
+    const side = getSide(threadEl);
+    const range = getRange(threadEl);
+    if (!side || !range) return undefined;
     return this._indexOfCommentRange(side, range);
   }
 
@@ -345,7 +364,7 @@
     const side = this.diffBuilder.getSideByLineEl(lineEl);
     if (!side) return null;
     const line = this.diffBuilder.getLineNumberByChild(lineEl);
-    if (!line || line === FILE) return null;
+    if (!line || line === FILE || line === 'LOST') return null;
     const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
     if (!contentTd) return null;
     const contentText = contentTd.querySelector('.contentText');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
index 39d5c2a..07e83a8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
@@ -151,7 +151,7 @@
     test('comment-thread-mouseenter from line comments is ignored', () => {
       const threadEl = document.createElement('div');
       threadEl.className = 'comment-thread';
-      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('diff-side', 'right');
       threadEl.setAttribute('line-num', 3);
       element.appendChild(threadEl);
       element.commentRanges = [{side: 'right'}];
@@ -165,7 +165,7 @@
     test('comment-thread-mouseenter from ranged comment causes set', () => {
       const threadEl = document.createElement('div');
       threadEl.className = 'comment-thread';
-      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('diff-side', 'right');
       threadEl.setAttribute('line-num', 3);
       threadEl.setAttribute('range', JSON.stringify({
         start_line: 3,
@@ -193,7 +193,7 @@
     test('comment-thread-mouseleave from line comments is ignored', () => {
       const threadEl = document.createElement('div');
       threadEl.className = 'comment-thread';
-      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('diff-side', 'right');
       threadEl.setAttribute('line-num', 3);
       element.appendChild(threadEl);
       element.commentRanges = [{side: 'right'}];
@@ -266,10 +266,8 @@
 
     setup(() => {
       contentStubs = [];
-      stub('gr-selection-action-box', {
-        placeAbove: sinon.stub(),
-        placeBelow: sinon.stub(),
-      });
+      stub('gr-selection-action-box', 'placeAbove');
+      stub('gr-selection-action-box', 'placeBelow');
       diff = element.querySelector('#diffTable');
       builder = {
         getContentTdByLine: sinon.stub(),
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index c6f5d21..1f4b778 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -14,64 +14,73 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-comment-thread/gr-comment-thread';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import '../gr-diff/gr-diff';
-import '../gr-syntax-layer/gr-syntax-layer';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-host_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {rangesEqual} from '../gr-diff/gr-diff-utils';
+import {
+  getLine,
+  getRange,
+  getSide,
+  rangesEqual,
+} from '../gr-diff/gr-diff-utils';
 import {appContext} from '../../../services/app-context';
 import {
   getParentIndex,
+  isAParent,
   isMergeParent,
   isNumber,
 } from '../../../utils/patch-set-util';
-import {
-  Comment,
-  isDraft,
-  sortComments,
-  UIComment,
-} from '../../../utils/comment-util';
-import {TwoSidesComments} from '../gr-comment-api/gr-comment-api';
+import {CommentThread} from '../../../utils/comment-util';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
   CommitRange,
   CoverageRange,
   DiffLayer,
   DiffLayerListener,
+  PatchSetFile,
 } from '../../../types/types';
 import {
   Base64ImageFile,
   BlameInfo,
   ChangeInfo,
   CommentRange,
-  DiffInfo,
-  DiffPreferencesInfo,
+  EditPatchSetNum,
   NumericChangeId,
+  ParentPatchSetNum,
   PatchRange,
   PatchSetNum,
   RepoName,
 } from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
-import {GrDiff, LineOfInterest} from '../gr-diff/gr-diff';
-import {GrSyntaxLayer} from '../gr-syntax-layer/gr-syntax-layer';
 import {
-  DiffViewMode,
+  DiffInfo,
+  DiffPreferencesInfo,
   IgnoreWhitespaceType,
-  Side,
-} from '../../../constants/constants';
+} from '../../../types/diff';
+import {
+  CreateCommentEventDetail,
+  GrDiff,
+  LineOfInterest,
+} from '../gr-diff/gr-diff';
+import {GrSyntaxLayer} from '../gr-syntax-layer/gr-syntax-layer';
+import {DiffViewMode, Side, CommentSide} from '../../../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
-import {LineNumber} from '../gr-diff/gr-diff-line';
+import {LineNumber, FILE} from '../gr-diff/gr-diff-line';
 import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
-import {PatchSetFile} from '../../../types/types';
 import {KnownExperimentId} from '../../../services/flags/flags';
+import {
+  firePageError,
+  fireAlert,
+  fireServerError,
+  fireEvent,
+  waitForEventOnce,
+} from '../../../utils/event-util';
+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 {Timing} from '../../../constants/reporting';
 
 const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -79,12 +88,6 @@
 const EVENT_ZERO_REBASE = 'rebase-percent-zero';
 const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
 
-const TimingLabel = {
-  TOTAL: 'Diff Total Render',
-  CONTENT: 'Diff Content Render',
-  SYNTAX: 'Diff Syntax Render',
-};
-
 // Disable syntax highlighting if the overall diff is too large.
 const SYNTAX_MAX_DIFF_LENGTH = 20000;
 
@@ -109,25 +112,8 @@
   afterNumber?: LineNumber;
 }
 
-// TODO(TS): Consolidate this with the CommentThread interface of comment-api.
-// What is being used here is just a local object for collecting all the data
-// that is needed to create a GrCommentThread component, see
-// _createThreadElement().
-interface CommentThread {
-  comments: UIComment[];
-  // In the context of a diff each thread must have a side!
-  commentSide: Side;
-  patchNum?: PatchSetNum;
-  lineNum?: LineNumber;
-  isOnParent?: boolean;
-  range?: CommentRange;
-}
-
 export interface GrDiffHost {
   $: {
-    restAPI: RestApiService & Element;
-    jsAPI: JsApiService & Element;
-    syntaxLayer: GrSyntaxLayer & Element;
     diff: GrDiff;
   };
 }
@@ -140,9 +126,7 @@
  * specific component, while <gr-diff> is a re-usable component.
  */
 @customElement('gr-diff-host')
-export class GrDiffHost extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffHost extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -211,8 +195,8 @@
   @property({type: Boolean})
   noRenderOnPrefsChange = false;
 
-  @property({type: Object, observer: '_commentsChanged'})
-  comments?: TwoSidesComments;
+  @property({type: Object, observer: '_threadsChanged'})
+  threads?: CommentThread[];
 
   @property({type: Boolean})
   lineWrapping = false;
@@ -245,7 +229,7 @@
   @property({type: Object})
   _revisionImage: Base64ImageFile | null = null;
 
-  @property({type: Object, notify: true})
+  @property({type: Object, notify: true, observer: 'diffChanged'})
   diff?: DiffInfo;
 
   @property({type: Object})
@@ -266,6 +250,7 @@
   @property({
     type: Boolean,
     computed: '_isSyntaxHighlightingEnabled(prefs.*, diff)',
+    observer: '_syntaxHighlightingEnabledChanged',
   })
   _syntaxHighlightingEnabled?: boolean;
 
@@ -276,9 +261,14 @@
 
   private readonly flags = appContext.flagsService;
 
-  /** @override */
-  created() {
-    super.created();
+  private readonly restApiService = appContext.restApiService;
+
+  private readonly jsAPI = appContext.jsApiService;
+
+  private readonly syntaxLayer = new GrSyntaxLayer();
+
+  constructor() {
+    super();
     this.addEventListener(
       // These are named inconsistently for a reason:
       // The create-comment event is fired to indicate that we should
@@ -289,11 +279,12 @@
       'create-comment',
       e => this._handleCreateComment(e)
     );
-    this.addEventListener('comment-discard', e =>
-      this._handleCommentDiscard(e)
+    this.addEventListener('comment-discard', () =>
+      this._handleCommentSaveOrDiscard()
     );
-    this.addEventListener('comment-update', e => this._handleCommentUpdate(e));
-    this.addEventListener('comment-save', e => this._handleCommentSave(e));
+    this.addEventListener('comment-save', () =>
+      this._handleCommentSaveOrDiscard()
+    );
     this.addEventListener('render-start', () => this._handleRenderStart());
     this.addEventListener('render-content', () => this._handleRenderContent());
     this.addEventListener('normalize-range', event =>
@@ -313,55 +304,76 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     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();
+      });
+  }
+
+  diffChanged(diff?: DiffInfo) {
+    this.syntaxLayer.init(diff);
   }
 
   /**
    * @param shouldReportMetric indicate a new Diff Page. This is a
    * signal to report metrics event that started on location change.
-   * @return
    */
   async reload(shouldReportMetric?: boolean) {
     this.clear();
-    if (!this.path) throw new Error('Missing required "path" property.');
-    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.changeNum, 'changeNum');
     this.diff = undefined;
     this._errorMessage = null;
     const whitespaceLevel = this._getIgnoreWhitespace();
 
-    this._layers = this._getLayers(this.path, this.changeNum);
-
     if (shouldReportMetric) {
       // We listen on render viewport only on DiffPage (on paramsChanged)
       this._listenToViewportRender();
     }
 
-    this._coverageRanges = [];
-    this._getCoverageData();
-
     try {
+      // We are carefully orchestrating operations that have to wait for another
+      // and operations that can be run in parallel. Plugins may provide layers,
+      // so we have to wait on plugins being loaded before we can initialize
+      // layers and proceed to rendering. OTOH we want to fetch diffs and diff
+      // assets in parallel.
+      const layerPromise = this.initLayers();
       const diff = await this._getDiff();
       this._loadedWhitespaceLevel = whitespaceLevel;
       this._reportDiff(diff);
 
       await this._loadDiffAssets(diff);
+      // Only now we are awaiting layers (and plugin loading), which was kicked
+      // off above.
+      await layerPromise;
 
       // Not waiting for coverage ranges intentionally as
       // plugin loading should not block the content rendering
 
       this.filesWeblinks = this._getFilesWeblinks(diff);
       this.diff = diff;
-      const event = await this._onRenderOnce();
+      const event = (await waitForEventOnce(this, 'render')) as CustomEvent;
       if (shouldReportMetric) {
         // We report diffViewContentDisplayed only on reload caused
         // by params changed - expected only on Diff Page.
@@ -369,11 +381,11 @@
       }
       const needsSyntaxHighlighting = !!event.detail?.contentRendered;
       if (needsSyntaxHighlighting) {
-        this.reporting.time(TimingLabel.SYNTAX);
+        this.reporting.time(Timing.DIFF_SYNTAX);
         try {
-          await this.$.syntaxLayer.process();
+          await this.syntaxLayer.process();
         } finally {
-          this.reporting.timeEnd(TimingLabel.SYNTAX);
+          this.reporting.timeEnd(Timing.DIFF_SYNTAX);
         }
       }
     } catch (e) {
@@ -383,35 +395,25 @@
         console.warn('Error encountered loading diff:', e);
       }
     } finally {
-      this.reporting.timeEnd(TimingLabel.TOTAL);
+      this.reporting.timeEnd(Timing.DIFF_TOTAL);
     }
   }
 
-  private _getLayers(path: string, changeNum: NumericChangeId): DiffLayer[] {
+  private _getLayers(path: string): DiffLayer[] {
     // Get layers from plugins (if any).
-    return [this.$.syntaxLayer, ...this.$.jsAPI.getDiffLayers(path, changeNum)];
-  }
-
-  private _onRenderOnce(): Promise<CustomEvent> {
-    return new Promise<CustomEvent>(resolve => {
-      const callback = (event: CustomEvent) => {
-        this.removeEventListener('render', callback);
-        resolve(event);
-      };
-      this.addEventListener('render', callback);
-    });
+    return [this.syntaxLayer, ...this.jsAPI.getDiffLayers(path)];
   }
 
   clear() {
-    if (this.path) this.$.jsAPI.disposeDiffLayers(this.path);
+    if (this.path) this.jsAPI.disposeDiffLayers(this.path);
     this._layers = [];
   }
 
   _getCoverageData() {
-    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
-    if (!this.change) throw new Error('Missing required "change" prop.');
-    if (!this.path) throw new Error('Missing required "path" prop.');
-    if (!this.patchRange) throw new Error('Missing required "patchRange".');
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchRange, 'patchRange');
     const changeNum = this.changeNum;
     const change = this.change;
     const path = this.path;
@@ -422,7 +424,7 @@
 
     const basePatchNum = toNumberOnly(this.patchRange.basePatchNum);
     const patchNum = toNumberOnly(this.patchRange.patchNum);
-    this.$.jsAPI
+    this.jsAPI
       .getCoverageAnnotationApis()
       .then(coverageAnnotationApis => {
         coverageAnnotationApis.forEach(coverageAnnotationApi => {
@@ -430,7 +432,7 @@
           if (!provider) return;
           provider(changeNum, path, basePatchNum, patchNum, change)
             .then(coverageRanges => {
-              if (!this.patchRange) throw new Error('Missing "patchRange".');
+              assertIsDefined(this.patchRange, 'patchRange');
               if (
                 !coverageRanges ||
                 changeNum !== this.changeNum ||
@@ -483,13 +485,13 @@
         this.projectName,
         this.commitRange.baseCommit,
         this.path,
-        {weblinks: diff && diff.meta_a && diff.meta_a.web_links}
+        {weblinks: diff?.meta_a?.web_links}
       ),
       meta_b: GerritNav.getFileWebLinks(
         this.projectName,
         this.commitRange.commit,
         this.path,
-        {weblinks: diff && diff.meta_b && diff.meta_b.web_links}
+        {weblinks: diff?.meta_b?.web_links}
       ),
     };
   }
@@ -497,7 +499,7 @@
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
     this.$.diff.cancel();
-    this.$.syntaxLayer.cancel();
+    this.syntaxLayer.cancel();
   }
 
   getCursorStops() {
@@ -520,20 +522,14 @@
    * Load and display blame information for the base of the diff.
    */
   loadBlame(): Promise<BlameInfo[]> {
-    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
-    if (!this.patchRange) throw new Error('Missing required "patchRange".');
-    if (!this.path) throw new Error('Missing required "path" property.');
-    return this.$.restAPI
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.path, 'path');
+    return this.restApiService
       .getBlame(this.changeNum, this.patchRange.patchNum, this.path, true)
       .then(blame => {
         if (!blame || !blame.length) {
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {message: MSG_EMPTY_BLAME},
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fireAlert(this, MSG_EMPTY_BLAME);
           return Promise.reject(MSG_EMPTY_BLAME);
         }
 
@@ -558,12 +554,12 @@
     this.$.diff.clearDiffContent();
   }
 
-  expandAllContext() {
-    this.$.diff.expandAllContext();
+  toggleAllContext() {
+    this.$.diff.toggleAllContext();
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   _canReload() {
@@ -593,10 +589,10 @@
     // Wrap the diff request in a new promise so that the error handler
     // rejects the promise, allowing the error to be handled in the .catch.
     return new Promise((resolve, reject) => {
-      if (!this.changeNum) throw new Error('Missing required "changeNum".');
-      if (!this.patchRange) throw new Error('Missing required "patchRange".');
-      if (!this.path) throw new Error('Missing required "path" property.');
-      this.$.restAPI
+      assertIsDefined(this.changeNum, 'changeNum');
+      assertIsDefined(this.patchRange, 'patchRange');
+      assertIsDefined(this.path, 'path');
+      this.restApiService
         .getDiff(
           this.changeNum,
           this.patchRange.basePatchNum,
@@ -605,7 +601,7 @@
           this._getIgnoreWhitespace(),
           reject
         )
-        .then(resolve);
+        .then(diff => resolve(diff!)); // reject is called in case of error, so we can't get undefined here
     });
   }
 
@@ -613,13 +609,7 @@
     // Loading the diff may respond with 409 if the file is too large. In this
     // case, use a toast error..
     if (response.status === 409) {
-      this.dispatchEvent(
-        new CustomEvent('server-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireServerError(response);
       return;
     }
 
@@ -632,13 +622,7 @@
       return;
     }
 
-    this.dispatchEvent(
-      new CustomEvent('page-error', {
-        detail: {response},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    firePageError(response);
   }
 
   /**
@@ -675,7 +659,7 @@
 
     // Report the due_to_rebase percentage in the "diff" category when
     // applicable.
-    if (!this.patchRange) throw new Error('Missing required "patchRange".');
+    assertIsDefined(this.patchRange, 'patchRange');
     if (this.patchRange.basePatchNum === 'PARENT') {
       this.reporting.reportInteraction(EVENT_AGAINST_PARENT);
     } else if (percentRebaseDelta === 0) {
@@ -705,58 +689,26 @@
     return isImageDiff(diff);
   }
 
-  _commentsChanged(newComments: TwoSidesComments) {
-    const allComments = [];
-    for (const side of [Side.LEFT, Side.RIGHT]) {
-      // This is needed by the threading.
-      for (const comment of newComments[side]) {
-        comment.__commentSide = side;
-      }
-      allComments.push(...newComments[side]);
-    }
+  _threadsChanged(threads: CommentThread[]) {
     // Currently, the only way this is ever changed here is when the initial
-    // comments are loaded, so it's okay performance wise to clear the threads
+    // threads are loaded, so it's okay performance wise to clear the threads
     // and recreate them. If this changes in future, we might want to reuse
     // some DOM nodes here.
     this._clearThreads();
-    const threads = this._createThreads(allComments);
     for (const thread of threads) {
       const threadEl = this._createThreadElement(thread);
       this._attachThreadElement(threadEl);
     }
-  }
-
-  _createThreads(comments: UIComment[]): CommentThread[] {
-    const sortedComments = sortComments(comments);
-    const threads = [];
-    for (const comment of sortedComments) {
-      // If the comment is in reply to another comment, find that comment's
-      // thread and append to it.
-      if (comment.in_reply_to) {
-        const thread = threads.find(thread =>
-          thread.comments.some(c => c.id === comment.in_reply_to)
-        );
-        if (thread) {
-          thread.comments.push(comment);
-          continue;
-        }
-      }
-
-      // Otherwise, this comment starts its own thread.
-      if (!comment.__commentSide) throw new Error('Missing "__commentSide".');
-      const newThread: CommentThread = {
-        comments: [comment],
-        commentSide: comment.__commentSide,
-        patchNum: comment.patch_set,
-        lineNum: comment.line,
-        isOnParent: comment.side === 'PARENT',
-      };
-      if (comment.range) {
-        newThread.range = {...comment.range};
-      }
-      threads.push(newThread);
+    const portedThreadsCount = threads.filter(thread => thread.ported).length;
+    const portedThreadsWithoutRange = threads.filter(
+      thread => thread.ported && thread.rangeInfoLost
+    ).length;
+    if (portedThreadsCount > 0) {
+      this.reporting.reportInteraction('ported-threads-shown', {
+        ported: portedThreadsCount,
+        portedThreadsWithoutRange,
+      });
     }
-    return threads;
   }
 
   _computeIsBlameLoaded(blame: BlameInfo[] | null) {
@@ -764,29 +716,75 @@
   }
 
   _getImages(diff: DiffInfo) {
-    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
-    if (!this.patchRange) throw new Error('Missing required "patchRange".');
-    return this.$.restAPI.getImagesForDiff(
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.patchRange, 'patchRange');
+    return this.restApiService.getImagesForDiff(
       this.changeNum,
       diff,
       this.patchRange
     );
   }
 
-  _handleCreateComment(e: CustomEvent) {
-    const {lineNum, side, patchNum, isOnParent, range} = e.detail;
+  _handleCreateComment(e: CustomEvent<CreateCommentEventDetail>) {
+    if (!this.patchRange) throw Error('patch range not set');
+
+    const {lineNum, side, range, path} = e.detail;
+
+    // Usually, the comment is stored on the patchset shown on the side the
+    // user added the comment on, and the commentSide will be REVISION.
+    // However, if the comment is added on the left side of the diff and the
+    // version shown there is not a patchset that is part the change, but
+    // instead a base (a PARENT or a merge parent commit), the comment is
+    // stored on the patchset shown on the right, and commentSide=PARENT
+    // indicates that the comment should still be shown on the left side.
+    const patchNum =
+      side === Side.LEFT && !isAParent(this.patchRange.basePatchNum)
+        ? this.patchRange.basePatchNum
+        : this.patchRange.patchNum;
+    const commentSide =
+      side === Side.LEFT && isAParent(this.patchRange.basePatchNum)
+        ? CommentSide.PARENT
+        : CommentSide.REVISION;
+    if (!this.canCommentOnPatchSetNum(patchNum)) return;
     const threadEl = this._getOrCreateThread(
       patchNum,
       lineNum,
       side,
-      range,
-      isOnParent
+      commentSide,
+      path,
+      range
     );
     threadEl.addOrEditDraft(lineNum, range);
 
     this.reporting.recordDraftInteraction();
   }
 
+  private canCommentOnPatchSetNum(patchNum: PatchSetNum) {
+    if (!this._loggedIn) {
+      fireEvent(this, 'show-auth-required');
+      return false;
+    }
+    if (!this.patchRange) {
+      fireAlert(this, 'Cannot create comment. patchRange undefined.');
+      return false;
+    }
+
+    const isEdit = patchNum === EditPatchSetNum;
+    const isEditBase =
+      patchNum === ParentPatchSetNum &&
+      this.patchRange.patchNum === EditPatchSetNum;
+
+    if (isEdit) {
+      fireAlert(this, 'You cannot comment on an edit.');
+      return false;
+    }
+    if (isEditBase) {
+      fireAlert(this, 'You cannot comment on the base patchset of an edit.');
+      return false;
+    }
+    return true;
+  }
+
   /**
    * Gets or creates a comment thread at a given location.
    * May provide a range, to get/create a range comment.
@@ -794,19 +792,21 @@
   _getOrCreateThread(
     patchNum: PatchSetNum,
     lineNum: LineNumber | undefined,
-    commentSide: Side,
-    range?: CommentRange,
-    isOnParent?: boolean
+    diffSide: Side,
+    commentSide: CommentSide,
+    path: string,
+    range?: CommentRange
   ): GrCommentThread {
-    let threadEl = this._getThreadEl(lineNum, commentSide, range);
+    let threadEl = this._getThreadEl(lineNum, diffSide, range);
     if (!threadEl) {
       threadEl = this._createThreadElement({
         comments: [],
+        path,
+        diffSide,
         commentSide,
         patchNum,
-        lineNum,
+        line: lineNum,
         range,
-        isOnParent,
       });
       this._attachThreadElement(threadEl);
     }
@@ -827,18 +827,21 @@
   _createThreadElement(thread: CommentThread) {
     const threadEl = document.createElement('gr-comment-thread');
     threadEl.className = 'comment-thread';
-    threadEl.setAttribute('slot', `${thread.commentSide}-${thread.lineNum}`);
+    threadEl.setAttribute(
+      'slot',
+      `${thread.diffSide}-${thread.line || 'LOST'}`
+    );
     threadEl.comments = thread.comments;
-    threadEl.commentSide = thread.commentSide;
-    threadEl.isOnParent = !!thread.isOnParent;
+    threadEl.diffSide = thread.diffSide;
+    threadEl.isOnParent = thread.commentSide === CommentSide.PARENT;
     threadEl.parentIndex = this._parentIndex;
     // Use path before renmaing when comment added on the left when comparing
     // two patch sets (not against base)
     if (
       this.file &&
       this.file.basePath &&
-      thread.commentSide === Side.LEFT &&
-      !thread.isOnParent
+      thread.diffSide === Side.LEFT &&
+      !threadEl.isOnParent
     ) {
       threadEl.path = this.file.basePath;
     } else {
@@ -847,8 +850,10 @@
     threadEl.changeNum = this.changeNum;
     threadEl.patchNum = thread.patchNum;
     threadEl.showPatchset = false;
+    threadEl.showPortedComment = !!thread.ported;
+    if (thread.rangeInfoLost) threadEl.lineNum = 'LOST';
     // GrCommentThread does not understand 'FILE', but requires undefined.
-    threadEl.lineNum = thread.lineNum !== 'FILE' ? thread.lineNum : undefined;
+    else threadEl.lineNum = thread.line !== 'FILE' ? thread.line : undefined;
     threadEl.projectName = this.projectName;
     threadEl.range = thread.range;
     const threadDiscardListener = (e: Event) => {
@@ -879,11 +884,7 @@
       throw new Error(`Unknown side: ${commentSide}`);
     }
     function matchesRange(threadEl: GrCommentThread) {
-      const rangeAtt = threadEl.getAttribute('range');
-      const threadRange = rangeAtt
-        ? (JSON.parse(rangeAtt) as CommentRange)
-        : undefined;
-      return rangesEqual(threadRange, range);
+      return rangesEqual(getRange(threadEl), range);
     }
 
     const filteredThreadEls = this._filterThreadElsForLocation(
@@ -901,35 +902,29 @@
   ) {
     function matchesLeftLine(threadEl: GrCommentThread) {
       return (
-        threadEl.getAttribute('comment-side') === Side.LEFT &&
-        threadEl.getAttribute('line-num') === String(lineInfo.beforeNumber)
+        getSide(threadEl) === Side.LEFT &&
+        getLine(threadEl) === lineInfo.beforeNumber
       );
     }
     function matchesRightLine(threadEl: GrCommentThread) {
       return (
-        threadEl.getAttribute('comment-side') === Side.RIGHT &&
-        threadEl.getAttribute('line-num') === String(lineInfo.afterNumber)
+        getSide(threadEl) === Side.RIGHT &&
+        getLine(threadEl) === lineInfo.afterNumber
       );
     }
     function matchesFileComment(threadEl: GrCommentThread) {
-      return (
-        threadEl.getAttribute('comment-side') === side &&
-        // line/range comments have 1-based line set, if line is falsy it's
-        // a file comment
-        !threadEl.getAttribute('line-num')
-      );
+      return getSide(threadEl) === side && getLine(threadEl) === FILE;
     }
 
     // Select the appropriate matchers for the desired side and line
-    // If side is BOTH, we want both the left and right matcher.
     const matchers: ((thread: GrCommentThread) => boolean)[] = [];
-    if (side !== Side.RIGHT) {
+    if (side === Side.LEFT) {
       matchers.push(matchesLeftLine);
     }
-    if (side !== Side.LEFT) {
+    if (side === Side.RIGHT) {
       matchers.push(matchesRightLine);
     }
-    if (lineInfo.afterNumber === 'FILE' || lineInfo.beforeNumber === 'FILE') {
+    if (lineInfo.afterNumber === FILE || lineInfo.beforeNumber === FILE) {
       matchers.push(matchesFileComment);
     }
     return threadEls.filter(threadEl =>
@@ -939,7 +934,7 @@
 
   _getIgnoreWhitespace(): IgnoreWhitespaceType {
     if (!this.prefs || !this.prefs.ignore_whitespace) {
-      return IgnoreWhitespaceType.IGNORE_NONE;
+      return 'IGNORE_NONE';
     }
     return this.prefs.ignore_whitespace;
   }
@@ -991,77 +986,12 @@
       : null;
   }
 
-  _handleCommentSave(e: CustomEvent) {
-    const comment = e.detail.comment;
-    const side = e.detail.comment.__commentSide;
-    const idx = this._findDraftIndex(comment, side);
-    this.set(['comments', side, idx], comment);
-    this._handleCommentSaveOrDiscard();
-  }
-
-  _handleCommentDiscard(e: CustomEvent) {
-    const comment = e.detail.comment;
-    this._removeComment(comment);
-    this._handleCommentSaveOrDiscard();
-  }
-
-  _handleCommentUpdate(e: CustomEvent) {
-    const comment = e.detail.comment;
-    const side = e.detail.comment.__commentSide;
-    let idx = this._findCommentIndex(comment, side);
-    if (idx === -1) {
-      idx = this._findDraftIndex(comment, side);
-    }
-    if (idx !== -1) {
-      // Update draft or comment.
-      this.set(['comments', side, idx], comment);
-    } else {
-      // Create new draft.
-      this.push(['comments', side], comment);
-    }
-  }
-
   _handleCommentSaveOrDiscard() {
-    this.dispatchEvent(
-      new CustomEvent('diff-comments-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'diff-comments-modified');
   }
 
-  _removeComment(comment: UIComment) {
-    const side = comment.__commentSide;
-    if (!side) throw new Error('Missing required "side" in comment.');
-    this._removeCommentFromSide(comment, side);
-  }
-
-  _removeCommentFromSide(comment: Comment, side: Side) {
-    let idx = this._findCommentIndex(comment, side);
-    if (idx === -1) {
-      idx = this._findDraftIndex(comment, side);
-    }
-    if (idx !== -1) {
-      this.splice('comments.' + side, idx, 1);
-    }
-  }
-
-  _findCommentIndex(comment: Comment, side: Side) {
-    if (!comment.id || !this.comments || !this.comments[side]) {
-      return -1;
-    }
-    return this.comments[side].findIndex(item => item.id === comment.id);
-  }
-
-  _findDraftIndex(comment: Comment, side: Side) {
-    if (
-      !isDraft(comment) ||
-      !comment.__draftID ||
-      !this.comments ||
-      !this.comments[side]
-    ) {
-      return -1;
-    }
-    return this.comments[side].findIndex(
-      item => isDraft(item) && item.__draftID === comment.__draftID
-    );
+  _syntaxHighlightingEnabledChanged(_syntaxHighlightingEnabled: boolean) {
+    this.syntaxLayer.setEnabled(_syntaxHighlightingEnabled);
   }
 
   _isSyntaxHighlightingEnabled(
@@ -1071,18 +1001,26 @@
     >,
     diff?: DiffInfo
   ) {
-    if (
-      !preferenceChangeRecord ||
-      !preferenceChangeRecord.base ||
-      !preferenceChangeRecord.base.syntax_highlighting ||
-      !diff
-    ) {
+    if (!preferenceChangeRecord?.base?.syntax_highlighting || !diff) {
       return false;
     }
-    return (
-      !this._anyLineTooLong(diff) &&
-      this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH
-    );
+    if (this._anyLineTooLong(diff)) {
+      fireAlert(
+        this,
+        `Files with line longer than ${SYNTAX_MAX_LINE_LENGTH} characters` +
+          '  will not be syntax highlighted.'
+      );
+      return false;
+    }
+    if (this.$.diff.getDiffLength(diff) > SYNTAX_MAX_DIFF_LENGTH) {
+      fireAlert(
+        this,
+        `Files with more than ${SYNTAX_MAX_DIFF_LENGTH} lines` +
+          '  will not be syntax highlighted.'
+      );
+      return false;
+    }
+    return true;
   }
 
   /**
@@ -1103,20 +1041,20 @@
     const renderUpdateListener: DiffLayerListener = start => {
       if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
         this.reporting.diffViewDisplayed();
-        this.$.syntaxLayer.removeListener(renderUpdateListener);
+        this.syntaxLayer.removeListener(renderUpdateListener);
       }
     };
 
-    this.$.syntaxLayer.addListener(renderUpdateListener);
+    this.syntaxLayer.addListener(renderUpdateListener);
   }
 
   _handleRenderStart() {
-    this.reporting.time(TimingLabel.TOTAL);
-    this.reporting.time(TimingLabel.CONTENT);
+    this.reporting.time(Timing.DIFF_TOTAL);
+    this.reporting.time(Timing.DIFF_CONTENT);
   }
 
   _handleRenderContent() {
-    this.reporting.timeEnd(TimingLabel.CONTENT);
+    this.reporting.timeEnd(Timing.DIFF_CONTENT);
   }
 
   _handleNormalizeRange(event: CustomEvent) {
@@ -1126,9 +1064,9 @@
     });
   }
 
-  _handleDiffContextExpanded(event: CustomEvent) {
+  _handleDiffContextExpanded(e: CustomEvent<DiffContextExpandedEventDetail>) {
     this.reporting.reportInteraction('diff-context-expanded', {
-      numLines: event.detail.numLines,
+      numLines: e.detail.numLines,
     });
   }
 
@@ -1201,8 +1139,8 @@
     return this._hasTrailingNewlines(diff, false) === false;
   }
 
-  _useNewContextControls() {
-    return this.flags.isEnabled(KnownExperimentId.NEW_CONTEXT_CONTROLS);
+  _useNewImageDiffUi() {
+    return this.flags.isEnabled(KnownExperimentId.NEW_IMAGE_DIFF_UI);
   }
 }
 
@@ -1215,9 +1153,10 @@
 // TODO(TS): Be more specific than CustomEvent, which has detail:any.
 declare global {
   interface HTMLElementEventMap {
-    render: CustomEvent;
+    /* prettier-ignore */
+    'render': CustomEvent;
     'normalize-range': CustomEvent;
-    'diff-context-expanded': CustomEvent;
+    'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
     'create-comment': CustomEvent;
     'comment-discard': CustomEvent;
     'comment-update': CustomEvent;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
index 9921dd6..3dd2b24 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
@@ -21,7 +21,6 @@
     id="diff"
     change-num="[[changeNum]]"
     no-auto-render="[[noAutoRender]]"
-    patch-range="[[patchRange]]"
     path="[[path]]"
     prefs="[[prefs]]"
     display-line="[[displayLine]]"
@@ -41,14 +40,7 @@
     diff="[[diff]]"
     show-newline-warning-left="[[_showNewlineWarningLeft(diff)]]"
     show-newline-warning-right="[[_showNewlineWarningRight(diff)]]"
-    use-new-context-controls="[[_useNewContextControls()]]"
+    use-new-image-diff-ui="[[_useNewImageDiffUi()]]"
   >
   </gr-diff>
-  <gr-syntax-layer
-    id="syntaxLayer"
-    enabled="[[_syntaxHighlightingEnabled]]"
-    diff="[[diff]]"
-  ></gr-syntax-layer>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 37b3a50..5e1b5ff 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -20,250 +20,63 @@
 import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {sortComments} from '../../../utils/comment-util.js';
-import {Side} from '../../../constants/constants.js';
+import {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 {EditPatchSetNum, ParentPatchSetNum} from '../../../types/common.js';
 
 const basicFixture = fixtureFromElement('gr-diff-host');
 
 suite('gr-diff-host tests', () => {
   let element;
 
-  let getLoggedIn;
+  let loggedIn;
 
-  setup(() => {
-    getLoggedIn = false;
-    stub('gr-rest-api-interface', {
-      async getLoggedIn() { return getLoggedIn; },
-    });
+  setup(async () => {
+    loggedIn = false;
+    stubRestApi('getLoggedIn').callsFake(() => Promise.resolve(loggedIn));
     element = basicFixture.instantiate();
     element.changeNum = 123;
     element.path = 'some/path';
     sinon.stub(element.reporting, 'time');
     sinon.stub(element.reporting, 'timeEnd');
+    await flush();
   });
 
   suite('plugin layers', () => {
     const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
     setup(() => {
-      stub('gr-js-api-interface', {
-        getDiffLayers() { return pluginLayers; },
-      });
       element = basicFixture.instantiate();
+      sinon.stub(element.jsAPI, 'getDiffLayers').returns(pluginLayers);
       element.changeNum = 123;
       element.path = 'some/path';
     });
-    test('plugin layers requested', () => {
+    test('plugin layers requested', async () => {
       element.patchRange = {};
       element.change = createChange();
-      element.reload();
-      assert(element.$.jsAPI.getDiffLayers.called);
-    });
-  });
-
-  suite('handle comment-update', () => {
-    setup(() => {
-      sinon.stub(element, '_commentsChanged');
-      element.comments = {
-        meta: {
-          changeNum: '42',
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: 3,
-          },
-          path: '/path/to/foo',
-          projectConfig: {foo: 'bar'},
-        },
-        left: [
-          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-          {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        ],
-        right: [
-          {id: 'c1', __commentSide: 'right'},
-          {id: 'c2', __commentSide: 'right'},
-          {id: 'd1', __draft: true, __commentSide: 'right'},
-          {id: 'd2', __draft: true, __commentSide: 'right'},
-        ],
-      };
-    });
-
-    test('creating a draft', () => {
-      const comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
-        __commentSide: 'left'};
-      element.dispatchEvent(
-          new CustomEvent('comment-update', {
-            detail: {comment},
-            composed: true, bubbles: true,
-          }));
-      assert.include(element.comments.left, comment);
-    });
-
-    test('discarding a draft', () => {
-      const draftID = 'tempID';
-      const id = 'savedID';
-      const comment = {
-        __draft: true,
-        __draftID: draftID,
-        side: 'PARENT',
-        __commentSide: 'left',
-      };
-      const diffCommentsModifiedStub = sinon.stub();
-      element.addEventListener('diff-comments-modified',
-          diffCommentsModifiedStub);
-      element.comments.left.push(comment);
-      comment.id = id;
-      element.dispatchEvent(
-          new CustomEvent('comment-discard', {
-            detail: {comment},
-            composed: true, bubbles: true,
-          }));
-      const drafts = element.comments.left
-          .filter(item => item.__draftID === draftID);
-      assert.equal(drafts.length, 0);
-      assert.isTrue(diffCommentsModifiedStub.called);
-    });
-
-    test('saving a draft', () => {
-      const draftID = 'tempID';
-      const id = 'savedID';
-      const comment = {
-        __draft: true,
-        __draftID: draftID,
-        side: 'PARENT',
-        __commentSide: 'left',
-      };
-      const diffCommentsModifiedStub = sinon.stub();
-      element.addEventListener('diff-comments-modified',
-          diffCommentsModifiedStub);
-      element.comments.left.push(comment);
-      comment.id = id;
-      element.dispatchEvent(
-          new CustomEvent('comment-save', {
-            detail: {comment},
-            composed: true, bubbles: true,
-          }));
-      const drafts = element.comments.left
-          .filter(item => item.__draftID === draftID);
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].id, id);
-      assert.isTrue(diffCommentsModifiedStub.called);
-    });
-  });
-
-  test('remove comment', () => {
-    sinon.stub(element, '_commentsChanged');
-    element.comments = {
-      meta: {
-        changeNum: '42',
-        patchRange: {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        },
-        path: '/path/to/foo',
-        projectConfig: {foo: 'bar'},
-      },
-      left: [
-        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-      ],
-      right: [
-        {id: 'c1', __commentSide: 'right'},
-        {id: 'c2', __commentSide: 'right'},
-        {id: 'd1', __draft: true, __commentSide: 'right'},
-        {id: 'd2', __draft: true, __commentSide: 'right'},
-      ],
-    };
-
-    // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
-    // to believe that one object deepEquals another even when they do :-/.
-    assert.equal(JSON.stringify(element.comments), JSON.stringify({
-      meta: {
-        changeNum: '42',
-        patchRange: {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        },
-        path: '/path/to/foo',
-        projectConfig: {foo: 'bar'},
-      },
-      left: [
-        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-      ],
-      right: [
-        {id: 'c1', __commentSide: 'right'},
-        {id: 'c2', __commentSide: 'right'},
-        {id: 'd1', __draft: true, __commentSide: 'right'},
-        {id: 'd2', __draft: true, __commentSide: 'right'},
-      ],
-    }));
-
-    element._removeComment({id: 'bc2', side: 'PARENT',
-      __commentSide: 'left'});
-    assert.deepEqual(element.comments, {
-      meta: {
-        changeNum: '42',
-        patchRange: {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        },
-        path: '/path/to/foo',
-        projectConfig: {foo: 'bar'},
-      },
-      left: [
-        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-      ],
-      right: [
-        {id: 'c1', __commentSide: 'right'},
-        {id: 'c2', __commentSide: 'right'},
-        {id: 'd1', __draft: true, __commentSide: 'right'},
-        {id: 'd2', __draft: true, __commentSide: 'right'},
-      ],
-    });
-
-    element._removeComment({id: 'd2', __commentSide: 'right'});
-    assert.deepEqual(element.comments, {
-      meta: {
-        changeNum: '42',
-        patchRange: {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        },
-        path: '/path/to/foo',
-        projectConfig: {foo: 'bar'},
-      },
-      left: [
-        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-      ],
-      right: [
-        {id: 'c1', __commentSide: 'right'},
-        {id: 'c2', __commentSide: 'right'},
-        {id: 'd1', __draft: true, __commentSide: 'right'},
-      ],
+      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
+      await element.reload();
+      assert(element.jsAPI.getDiffLayers.called);
     });
   });
 
   test('thread-discard handling', () => {
-    const threads = element._createThreads([
+    const threads = createCommentThreads([
       {
         id: 4711,
-        __commentSide: 'left',
+        diffSide: Side.LEFT,
         updated: '2015-12-20 15:01:20.396000000',
+        patch_set: 1,
+        path: 'some/path',
       },
       {
         id: 42,
-        __commentSide: 'left',
+        diffSide: Side.LEFT,
         updated: '2017-12-20 15:01:20.396000000',
+        patch_set: 1,
+        path: 'some/path',
       },
     ]);
     element._parentIndex = 1;
@@ -289,8 +102,7 @@
 
     threadEls[0].dispatchEvent(
         new CustomEvent('thread-discard', {detail: {rootId: 4711}}));
-    const attachedThreads = element.queryAllEffectiveChildren(
-        'gr-comment-thread');
+    const attachedThreads = element.querySelectorAll('gr-comment-thread');
     assert.equal(attachedThreads.length, 1);
     assert.equal(attachedThreads[0].comments[0].id, 42);
   });
@@ -316,18 +128,16 @@
     test('ends total and syntax timer after syntax layer', async () => {
       sinon.stub(element.reporting, 'diffViewContentDisplayed');
       let notifySyntaxProcessed;
-      sinon.stub(element.$.syntaxLayer, 'process').returns(new Promise(
-          resolve => {
+      sinon.stub(element.syntaxLayer, 'process').returns(
+          new Promise(resolve => {
             notifySyntaxProcessed = resolve;
-          }));
-      sinon.stub(element.$.restAPI, 'getDiff').returns(
-          Promise.resolve({content: []}));
+          })
+      );
+      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       element.patchRange = {};
       element.change = createChange();
-      element.$.restAPI.getDiffPreferences().then(prefs => {
-        element.prefs = prefs;
-        return element.reload(true);
-      });
+      element.prefs = createDefaultDiffPrefs();
+      element.reload(true);
       // Multiple cascading microtasks are scheduled.
       await flush();
       notifySyntaxProcessed();
@@ -341,13 +151,13 @@
     });
 
     test('ends total timer w/ no syntax layer processing', async () => {
-      sinon.stub(element.$.restAPI, 'getDiff').returns(
-          Promise.resolve({content: []}));
+      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       element.patchRange = {};
       element.change = createChange();
       element.reload();
       // Multiple cascading microtasks are scheduled.
       await flush();
+      await flush();
       // Reporting can be called with other parameters (ex. PluginsLoaded),
       // but only 'Diff Total Render' is important in this test.
       assert.equal(
@@ -359,23 +169,19 @@
 
     test('completes reload promise after syntax layer processing', async () => {
       let notifySyntaxProcessed;
-      sinon.stub(element.$.syntaxLayer, 'process').returns(new Promise(
+      sinon.stub(element.syntaxLayer, 'process').returns(new Promise(
           resolve => {
             notifySyntaxProcessed = resolve;
           }));
-      sinon.stub(element.$.restAPI, 'getDiff').returns(
+      stubRestApi('getDiff').returns(
           Promise.resolve({content: []}));
       element.patchRange = {};
       element.change = createChange();
       let reloadComplete = false;
-      element.$.restAPI.getDiffPreferences()
-          .then(prefs => {
-            element.prefs = prefs;
-            return element.reload();
-          })
-          .then(() => {
-            reloadComplete = true;
-          });
+      element.prefs = createDefaultDiffPrefs();
+      element.reload().then(() => {
+        reloadComplete = true;
+      });
       // Multiple cascading microtasks are scheduled.
       await flush();
       assert.isFalse(reloadComplete);
@@ -405,435 +211,476 @@
     assert.isTrue(cancelStub.called);
   });
 
-  suite('not logged in', () => {
-    setup(() => {
-      getLoggedIn = false;
-      element = basicFixture.instantiate();
-      element.changeNum = 123;
-      element.change = createChange();
-      element.path = 'some/path';
-    });
-
-    test('reload() loads files weblinks', () => {
-      const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
-          .returns({name: 'stubb', url: '#s'});
-      sinon.stub(element.$.restAPI, 'getDiff').returns(Promise.resolve({
-        content: [],
-      }));
-      element.projectName = 'test-project';
-      element.path = 'test-path';
-      element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
-      element.patchRange = {};
-      return element.reload().then(() => {
-        assert.isTrue(weblinksStub.calledTwice);
-        assert.isTrue(weblinksStub.firstCall.calledWith({
-          commit: 'test-base',
-          file: 'test-path',
-          options: {
-            weblinks: undefined,
-          },
-          repo: 'test-project',
-          type: GerritNav.WeblinkType.FILE}));
-        assert.isTrue(weblinksStub.secondCall.calledWith({
-          commit: 'test-commit',
-          file: 'test-path',
-          options: {
-            weblinks: undefined,
-          },
-          repo: 'test-project',
-          type: GerritNav.WeblinkType.FILE}));
-        assert.deepEqual(element.filesWeblinks, {
-          meta_a: [{name: 'stubb', url: '#s'}],
-          meta_b: [{name: 'stubb', url: '#s'}],
-        });
+  test('reload() loads files weblinks', () => {
+    element.change = createChange();
+    const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
+        .returns({name: 'stubb', url: '#s'});
+    stubRestApi('getDiff').returns(Promise.resolve({
+      content: [],
+    }));
+    element.projectName = 'test-project';
+    element.path = 'test-path';
+    element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
+    element.patchRange = {};
+    return element.reload().then(() => {
+      assert.isTrue(weblinksStub.calledTwice);
+      assert.isTrue(weblinksStub.firstCall.calledWith({
+        commit: 'test-base',
+        file: 'test-path',
+        options: {
+          weblinks: undefined,
+        },
+        repo: 'test-project',
+        type: GerritNav.WeblinkType.FILE}));
+      assert.isTrue(weblinksStub.secondCall.calledWith({
+        commit: 'test-commit',
+        file: 'test-path',
+        options: {
+          weblinks: undefined,
+        },
+        repo: 'test-project',
+        type: GerritNav.WeblinkType.FILE}));
+      assert.deepEqual(element.filesWeblinks, {
+        meta_a: [{name: 'stubb', url: '#s'}],
+        meta_b: [{name: 'stubb', url: '#s'}],
       });
     });
+  });
 
-    test('prefetch getDiff', done => {
-      const diffRestApiStub = sinon.stub(element.$.restAPI, 'getDiff')
-          .returns(Promise.resolve({content: []}));
-      element.changeNum = 123;
-      element.patchRange = {basePatchNum: 1, patchNum: 2};
-      element.path = 'file.txt';
-      element.prefetchDiff();
-      element._getDiff().then(() =>{
-        assert.isTrue(diffRestApiStub.calledOnce);
+  test('prefetch getDiff', done => {
+    const diffRestApiStub = stubRestApi('getDiff')
+        .returns(Promise.resolve({content: []}));
+    element.changeNum = 123;
+    element.patchRange = {basePatchNum: 1, patchNum: 2};
+    element.path = 'file.txt';
+    element.prefetchDiff();
+    element._getDiff().then(() =>{
+      assert.isTrue(diffRestApiStub.calledOnce);
+      done();
+    });
+  });
+
+  test('_getDiff handles null diff responses', done => {
+    stubRestApi('getDiff').returns(Promise.resolve(null));
+    element.changeNum = 123;
+    element.patchRange = {basePatchNum: 1, patchNum: 2};
+    element.path = 'file.txt';
+    element._getDiff().then(done);
+  });
+
+  test('reload resolves on error', () => {
+    const onErrStub = sinon.stub(element, '_handleGetDiffError');
+    const error = new Response(null, {ok: false, status: 500});
+    stubRestApi('getDiff').callsFake(
+        (changeNum, basePatchNum, patchNum, path, whitespace, onErr) => {
+          onErr(error);
+        });
+    element.patchRange = {};
+    return element.reload().then(() => {
+      assert.isTrue(onErrStub.calledOnce);
+    });
+  });
+
+  suite('_handleGetDiffError', () => {
+    let serverErrorStub;
+    let pageErrorStub;
+
+    setup(() => {
+      serverErrorStub = sinon.stub();
+      addListenerForTest(document, 'server-error', serverErrorStub);
+      pageErrorStub = sinon.stub();
+      addListenerForTest(document, 'page-error', pageErrorStub);
+    });
+
+    test('page error on HTTP-409', () => {
+      element._handleGetDiffError({status: 409});
+      assert.isTrue(serverErrorStub.calledOnce);
+      assert.isFalse(pageErrorStub.called);
+      assert.isNotOk(element._errorMessage);
+    });
+
+    test('server error on non-HTTP-409', () => {
+      element._handleGetDiffError({
+        status: 500,
+        text: () => Promise.resolve(''),
+      });
+      assert.isFalse(serverErrorStub.called);
+      assert.isTrue(pageErrorStub.calledOnce);
+      assert.isNotOk(element._errorMessage);
+    });
+
+    test('error message if showLoadFailure', () => {
+      element.showLoadFailure = true;
+      element._handleGetDiffError({status: 500, statusText: 'Failure!'});
+      assert.isFalse(serverErrorStub.called);
+      assert.isFalse(pageErrorStub.called);
+      assert.equal(element._errorMessage,
+          'Encountered error when loading the diff: 500 Failure!');
+    });
+  });
+
+  suite('image diffs', () => {
+    let mockFile1;
+    let mockFile2;
+    setup(() => {
+      mockFile1 = {
+        body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+        'wsAAAAAAAAAAAAAAAAA/w==',
+        type: 'image/bmp',
+      };
+      mockFile2 = {
+        body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+        'wsAAAAAAAAAAAAA/////w==',
+        type: 'image/bmp',
+      };
+
+      element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+      element.change = createChange();
+      element.comments = {
+        left: [],
+        right: [],
+        meta: {patchRange: element.patchRange},
+      };
+    });
+
+    test('renders image diffs with same file name', done => {
+      const mockDiff = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+          lines: 560},
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot.jpg',
+          'index 2adc47d..f9c2f2c 100644',
+          '--- a/carrot.jpg',
+          '+++ b/carrot.jpg',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(Promise.resolve({
+        baseImage: {
+          ...mockFile1,
+          _expectedType: 'image/jpeg',
+          _name: 'carrot.jpg',
+        },
+        revisionImage: {
+          ...mockFile2,
+          _expectedType: 'image/jpeg',
+          _name: 'carrot.jpg',
+        },
+      }));
+
+      const rendered = () => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(
+            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+        // Left image rendered with the parent commit's version of the file.
+        const leftImage =
+            element.$.diff.$.diffTable.querySelector('td.left img');
+        const leftLabel =
+            element.$.diff.$.diffTable.querySelector('td.left label');
+        const leftLabelContent = leftLabel.querySelector('.label');
+        const leftLabelName = leftLabel.querySelector('.name');
+
+        const rightImage =
+            element.$.diff.$.diffTable.querySelector('td.right img');
+        const rightLabel = element.$.diff.$.diffTable.querySelector(
+            'td.right label');
+        const rightLabelContent = rightLabel.querySelector('.label');
+        const rightLabelName = rightLabel.querySelector('.name');
+
+        assert.isNotOk(rightLabelName);
+        assert.isNotOk(leftLabelName);
+
+        let leftLoaded = false;
+        let rightLoaded = false;
+
+        leftImage.addEventListener('load', () => {
+          assert.isOk(leftImage);
+          assert.equal(leftImage.getAttribute('src'),
+              'data:image/bmp;base64,' + mockFile1.body);
+          assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+          leftLoaded = true;
+          if (rightLoaded) {
+            element.removeEventListener('render', rendered);
+            done();
+          }
+        });
+
+        rightImage.addEventListener('load', () => {
+          assert.isOk(rightImage);
+          assert.equal(rightImage.getAttribute('src'),
+              'data:image/bmp;base64,' + mockFile2.body);
+          assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+
+          rightLoaded = true;
+          if (leftLoaded) {
+            element.removeEventListener('render', rendered);
+            done();
+          }
+        });
+      };
+
+      element.addEventListener('render', rendered);
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+    });
+
+    test('renders image diffs with a different file name', done => {
+      const mockDiff = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+        meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
+          lines: 560},
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot2.jpg',
+          'index 2adc47d..f9c2f2c 100644',
+          '--- a/carrot.jpg',
+          '+++ b/carrot2.jpg',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(Promise.resolve({
+        baseImage: {
+          ...mockFile1,
+          _expectedType: 'image/jpeg',
+          _name: 'carrot.jpg',
+        },
+        revisionImage: {
+          ...mockFile2,
+          _expectedType: 'image/jpeg',
+          _name: 'carrot2.jpg',
+        },
+      }));
+
+      const rendered = () => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(
+            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+        // Left image rendered with the parent commit's version of the file.
+        const leftImage =
+            element.$.diff.$.diffTable.querySelector('td.left img');
+        const leftLabel =
+            element.$.diff.$.diffTable.querySelector('td.left label');
+        const leftLabelContent = leftLabel.querySelector('.label');
+        const leftLabelName = leftLabel.querySelector('.name');
+
+        const rightImage =
+            element.$.diff.$.diffTable.querySelector('td.right img');
+        const rightLabel = element.$.diff.$.diffTable.querySelector(
+            'td.right label');
+        const rightLabelContent = rightLabel.querySelector('.label');
+        const rightLabelName = rightLabel.querySelector('.name');
+
+        assert.isOk(rightLabelName);
+        assert.isOk(leftLabelName);
+        assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
+        assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
+
+        let leftLoaded = false;
+        let rightLoaded = false;
+
+        leftImage.addEventListener('load', () => {
+          assert.isOk(leftImage);
+          assert.equal(leftImage.getAttribute('src'),
+              'data:image/bmp;base64,' + mockFile1.body);
+          assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+          leftLoaded = true;
+          if (rightLoaded) {
+            element.removeEventListener('render', rendered);
+            done();
+          }
+        });
+
+        rightImage.addEventListener('load', () => {
+          assert.isOk(rightImage);
+          assert.equal(rightImage.getAttribute('src'),
+              'data:image/bmp;base64,' + mockFile2.body);
+          assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+
+          rightLoaded = true;
+          if (leftLoaded) {
+            element.removeEventListener('render', rendered);
+            done();
+          }
+        });
+      };
+
+      element.addEventListener('render', rendered);
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+    });
+
+    test('renders added image', done => {
+      const mockDiff = {
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+          lines: 560},
+        intraline_status: 'OK',
+        change_type: 'ADDED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot.jpg',
+          'index 0000000..f9c2f2c 100644',
+          '--- /dev/null',
+          '+++ b/carrot.jpg',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(Promise.resolve({
+        baseImage: null,
+        revisionImage: {
+          ...mockFile2,
+          _expectedType: 'image/jpeg',
+          _name: 'carrot2.jpg',
+        },
+      }));
+
+      element.addEventListener('render', () => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(
+            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+        const leftImage =
+            element.$.diff.$.diffTable.querySelector('td.left img');
+        const rightImage =
+            element.$.diff.$.diffTable.querySelector('td.right img');
+
+        assert.isNotOk(leftImage);
+        assert.isOk(rightImage);
         done();
       });
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
     });
 
-    test('_getDiff handles null diff responses', done => {
-      stub('gr-rest-api-interface', {
-        getDiff() { return Promise.resolve(null); },
+    test('renders removed image', done => {
+      const mockDiff = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
+          lines: 560},
+        intraline_status: 'OK',
+        change_type: 'DELETED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot.jpg',
+          'index f9c2f2c..0000000 100644',
+          '--- a/carrot.jpg',
+          '+++ /dev/null',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(Promise.resolve({
+        baseImage: {
+          ...mockFile1,
+          _expectedType: 'image/jpeg',
+          _name: 'carrot.jpg',
+        },
+        revisionImage: null,
+      }));
+
+      element.addEventListener('render', () => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(
+            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+        const leftImage =
+            element.$.diff.$.diffTable.querySelector('td.left img');
+        const rightImage =
+            element.$.diff.$.diffTable.querySelector('td.right img');
+
+        assert.isOk(leftImage);
+        assert.isNotOk(rightImage);
+        done();
       });
-      element.changeNum = 123;
-      element.patchRange = {basePatchNum: 1, patchNum: 2};
-      element.path = 'file.txt';
-      element._getDiff().then(done);
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
     });
 
-    test('reload resolves on error', () => {
-      const onErrStub = sinon.stub(element, '_handleGetDiffError');
-      const error = new Response(null, {ok: false, status: 500});
-      sinon.stub(element.$.restAPI, 'getDiff').callsFake(
-          (changeNum, basePatchNum, patchNum, path, whitespace, onErr) => {
-            onErr(error);
-          });
-      element.patchRange = {};
-      return element.reload().then(() => {
-        assert.isTrue(onErrStub.calledOnce);
+    test('does not render disallowed image type', done => {
+      const mockDiff = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
+          lines: 560},
+        intraline_status: 'OK',
+        change_type: 'DELETED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot.jpg',
+          'index f9c2f2c..0000000 100644',
+          '--- a/carrot.jpg',
+          '+++ /dev/null',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      mockFile1.type = 'image/jpeg-evil';
+
+      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(Promise.resolve({
+        baseImage: {
+          ...mockFile1,
+          _expectedType: 'image/jpeg',
+          _name: 'carrot.jpg',
+        },
+        revisionImage: null,
+      }));
+
+      element.addEventListener('render', () => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(
+            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+        const leftImage =
+            element.$.diff.$.diffTable.querySelector('td.left img');
+        assert.isNotOk(leftImage);
+        done();
       });
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
     });
+  });
 
-    suite('_handleGetDiffError', () => {
-      let serverErrorStub;
-      let pageErrorStub;
+  test('cannot create comments when not logged in', () => {
+    element.patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 2,
+    };
+    const showAuthRequireSpy = sinon.spy();
+    element.addEventListener('show-auth-required', showAuthRequireSpy);
 
-      setup(() => {
-        serverErrorStub = sinon.stub();
-        element.addEventListener('server-error', serverErrorStub);
-        pageErrorStub = sinon.stub();
-        element.addEventListener('page-error', pageErrorStub);
-      });
+    element.dispatchEvent(new CustomEvent('create-comment', {
+      detail: {
+        lineNum: 3,
+        side: Side.LEFT,
+        path: '/p',
+      },
+    }));
 
-      test('page error on HTTP-409', () => {
-        element._handleGetDiffError({status: 409});
-        assert.isTrue(serverErrorStub.calledOnce);
-        assert.isFalse(pageErrorStub.called);
-        assert.isNotOk(element._errorMessage);
-      });
+    const threads = dom(element.$.diff)
+        .queryDistributedElements('gr-comment-thread');
 
-      test('server error on non-HTTP-409', () => {
-        element._handleGetDiffError({status: 500});
-        assert.isFalse(serverErrorStub.called);
-        assert.isTrue(pageErrorStub.calledOnce);
-        assert.isNotOk(element._errorMessage);
-      });
+    assert.equal(threads.length, 0);
 
-      test('error message if showLoadFailure', () => {
-        element.showLoadFailure = true;
-        element._handleGetDiffError({status: 500, statusText: 'Failure!'});
-        assert.isFalse(serverErrorStub.called);
-        assert.isFalse(pageErrorStub.called);
-        assert.equal(element._errorMessage,
-            'Encountered error when loading the diff: 500 Failure!');
-      });
-    });
-
-    suite('image diffs', () => {
-      let mockFile1;
-      let mockFile2;
-      setup(() => {
-        mockFile1 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAAAAAA/w==',
-          type: 'image/bmp',
-        };
-        mockFile2 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAA/////w==',
-          type: 'image/bmp',
-        };
-        sinon.stub(element.$.restAPI,
-            'getB64FileContents')
-            .callsFake(
-                (changeId, patchNum, path, opt_parentIndex) => Promise.resolve(
-                    opt_parentIndex === 1 ? mockFile1 : mockFile2)
-            );
-
-        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-        element.change = createChange();
-        element.comments = {
-          left: [],
-          right: [],
-          meta: {patchRange: element.patchRange},
-        };
-      });
-
-      test('renders image diffs with same file name', done => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        sinon.stub(element.$.restAPI, 'getDiff')
-            .returns(Promise.resolve(mockDiff));
-
-        const rendered = () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          // Left image rendered with the parent commit's version of the file.
-          const leftImage =
-              element.$.diff.$.diffTable.querySelector('td.left img');
-          const leftLabel =
-              element.$.diff.$.diffTable.querySelector('td.left label');
-          const leftLabelContent = leftLabel.querySelector('.label');
-          const leftLabelName = leftLabel.querySelector('.name');
-
-          const rightImage =
-              element.$.diff.$.diffTable.querySelector('td.right img');
-          const rightLabel = element.$.diff.$.diffTable.querySelector(
-              'td.right label');
-          const rightLabelContent = rightLabel.querySelector('.label');
-          const rightLabelName = rightLabel.querySelector('.name');
-
-          assert.isNotOk(rightLabelName);
-          assert.isNotOk(leftLabelName);
-
-          let leftLoaded = false;
-          let rightLoaded = false;
-
-          leftImage.addEventListener('load', () => {
-            assert.isOk(leftImage);
-            assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile1.body);
-            assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-            leftLoaded = true;
-            if (rightLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
-          });
-
-          rightImage.addEventListener('load', () => {
-            assert.isOk(rightImage);
-            assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile2.body);
-            assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-            rightLoaded = true;
-            if (leftLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
-          });
-        };
-
-        element.addEventListener('render', rendered);
-
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          element.reload();
-        });
-      });
-
-      test('renders image diffs with a different file name', done => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot2.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot2.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        sinon.stub(element.$.restAPI, 'getDiff')
-            .returns(Promise.resolve(mockDiff));
-
-        const rendered = () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          // Left image rendered with the parent commit's version of the file.
-          const leftImage =
-              element.$.diff.$.diffTable.querySelector('td.left img');
-          const leftLabel =
-              element.$.diff.$.diffTable.querySelector('td.left label');
-          const leftLabelContent = leftLabel.querySelector('.label');
-          const leftLabelName = leftLabel.querySelector('.name');
-
-          const rightImage =
-              element.$.diff.$.diffTable.querySelector('td.right img');
-          const rightLabel = element.$.diff.$.diffTable.querySelector(
-              'td.right label');
-          const rightLabelContent = rightLabel.querySelector('.label');
-          const rightLabelName = rightLabel.querySelector('.name');
-
-          assert.isOk(rightLabelName);
-          assert.isOk(leftLabelName);
-          assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-          assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
-
-          let leftLoaded = false;
-          let rightLoaded = false;
-
-          leftImage.addEventListener('load', () => {
-            assert.isOk(leftImage);
-            assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile1.body);
-            assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-            leftLoaded = true;
-            if (rightLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
-          });
-
-          rightImage.addEventListener('load', () => {
-            assert.isOk(rightImage);
-            assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile2.body);
-            assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-            rightLoaded = true;
-            if (leftLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
-          });
-        };
-
-        element.addEventListener('render', rendered);
-
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          element.reload();
-        });
-      });
-
-      test('renders added image', done => {
-        const mockDiff = {
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'ADDED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 0000000..f9c2f2c 100644',
-            '--- /dev/null',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        sinon.stub(element.$.restAPI, 'getDiff')
-            .returns(Promise.resolve(mockDiff));
-
-        element.addEventListener('render', () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          const leftImage =
-              element.$.diff.$.diffTable.querySelector('td.left img');
-          const rightImage =
-              element.$.diff.$.diffTable.querySelector('td.right img');
-
-          assert.isNotOk(leftImage);
-          assert.isOk(rightImage);
-          done();
-        });
-
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          element.reload();
-        });
-      });
-
-      test('renders removed image', done => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'DELETED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index f9c2f2c..0000000 100644',
-            '--- a/carrot.jpg',
-            '+++ /dev/null',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        sinon.stub(element.$.restAPI, 'getDiff')
-            .returns(Promise.resolve(mockDiff));
-
-        element.addEventListener('render', () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          const leftImage =
-              element.$.diff.$.diffTable.querySelector('td.left img');
-          const rightImage =
-              element.$.diff.$.diffTable.querySelector('td.right img');
-
-          assert.isOk(leftImage);
-          assert.isNotOk(rightImage);
-          done();
-        });
-
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          element.reload();
-        });
-      });
-
-      test('does not render disallowed image type', done => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'DELETED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index f9c2f2c..0000000 100644',
-            '--- a/carrot.jpg',
-            '+++ /dev/null',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        mockFile1.type = 'image/jpeg-evil';
-
-        sinon.stub(element.$.restAPI, 'getDiff')
-            .returns(Promise.resolve(mockDiff));
-
-        element.addEventListener('render', () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-          const leftImage =
-              element.$.diff.$.diffTable.querySelector('td.left img');
-          assert.isNotOk(leftImage);
-          done();
-        });
-
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          element.reload();
-        });
-      });
-    });
+    assert.isTrue(showAuthRequireSpy.called);
   });
 
   test('delegates cancel()', () => {
@@ -870,10 +717,11 @@
   });
 
   suite('blame', () => {
-    setup(() => {
+    setup(async () => {
       element = basicFixture.instantiate();
       element.changeNum = 123;
       element.path = 'some/path';
+      await flush();
     });
 
     test('clearBlame', () => {
@@ -889,7 +737,7 @@
       const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
       const showAlertStub = sinon.stub();
       element.addEventListener('show-alert', showAlertStub);
-      const getBlameStub = sinon.stub(element.$.restAPI, 'getBlame')
+      const getBlameStub = stubRestApi('getBlame')
           .returns(Promise.resolve(mockBlame));
       element.changeNum = 42;
       element.patchRange = {patchNum: 5, basePatchNum: 4};
@@ -907,7 +755,7 @@
       const mockBlame = [];
       const showAlertStub = sinon.stub();
       element.addEventListener('show-alert', showAlertStub);
-      sinon.stub(element.$.restAPI, 'getBlame')
+      stubRestApi('getBlame')
           .returns(Promise.resolve(mockBlame));
       element.changeNum = 42;
       element.patchRange = {patchNum: 5, basePatchNum: 4};
@@ -947,9 +795,9 @@
     assert.equal(stub.lastCall.args.length, 0);
   });
 
-  test('delegates expandAllContext()', () => {
-    const stub = sinon.stub(element.$.diff, 'expandAllContext');
-    element.expandAllContext();
+  test('delegates toggleAllContext()', () => {
+    const stub = sinon.stub(element.$.diff, 'toggleAllContext');
+    element.toggleAllContext();
     assert.isTrue(stub.calledOnce);
     assert.equal(stub.lastCall.args.length, 0);
   });
@@ -965,12 +813,6 @@
     assert.equal(element.$.diff.noAutoRender, value);
   });
 
-  test('passes in patchRange', () => {
-    const value = {patchNum: 'foo', basePatchNum: 'bar'};
-    element.patchRange = value;
-    assert.equal(element.$.diff.patchRange, value);
-  });
-
   test('passes in path', () => {
     const value = 'some/file/path';
     element.path = value;
@@ -1028,12 +870,13 @@
   suite('_reportDiff', () => {
     let reportStub;
 
-    setup(() => {
+    setup(async () => {
       element = basicFixture.instantiate();
       element.changeNum = 123;
       element.path = 'file.txt';
       element.patchRange = {basePatchNum: 1};
       reportStub = sinon.stub(element.reporting, 'reportInteraction');
+      await flush();
     });
 
     test('null and content-less', () => {
@@ -1123,255 +966,255 @@
     });
   });
 
-  test('comments sorting', () => {
-    const comments = [
-      {
-        id: 'new_draft',
-        message: 'i do not like either of you',
-        __commentSide: 'left',
-        __draft: true,
-        updated: '2015-12-20 15:01:20.396000000',
-      },
-      {
-        id: 'sallys_confession',
-        message: 'i like you, jack',
-        updated: '2015-12-23 15:00:20.396000000',
-        line: 1,
-        __commentSide: 'left',
-      }, {
-        id: 'jacks_reply',
-        message: 'i like you, too',
-        updated: '2015-12-24 15:01:20.396000000',
-        __commentSide: 'left',
-        line: 1,
-        in_reply_to: 'sallys_confession',
-      },
-    ];
-    const sortedComments = sortComments(comments);
-    assert.equal(sortedComments[0], comments[1]);
-    assert.equal(sortedComments[1], comments[2]);
-    assert.equal(sortedComments[2], comments[0]);
-  });
+  suite('create-comment', () => {
+    setup(async () => {
+      loggedIn = true;
+      element.connectedCallback();
+      await flush();
+    });
 
-  test('_createThreads', () => {
-    const comments = [
-      {
-        id: 'sallys_confession',
-        message: 'i like you, jack',
-        updated: '2015-12-23 15:00:20.396000000',
-        line: 1,
-        __commentSide: 'left',
-      }, {
-        id: 'jacks_reply',
-        message: 'i like you, too',
-        updated: '2015-12-24 15:01:20.396000000',
-        __commentSide: 'left',
-        line: 1,
-        in_reply_to: 'sallys_confession',
-      },
-      {
-        id: 'new_draft',
-        message: 'i do not like either of you',
-        __commentSide: 'left',
-        __draft: true,
-        updated: '2015-12-20 15:01:20.396000000',
-      },
-    ];
+    test('creates comments if they do not exist yet', () => {
+      const diffSide = Side.LEFT;
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
 
-    const actualThreads = element._createThreads(comments);
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          lineNum: 3,
+          side: diffSide,
+          path: '/p',
+        },
+      }));
 
-    assert.equal(actualThreads.length, 2);
+      let threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
 
-    assert.equal(actualThreads[0].commentSide, 'left');
-    assert.equal(actualThreads[0].comments.length, 2);
-    assert.deepEqual(actualThreads[0].comments[0], comments[0]);
-    assert.deepEqual(actualThreads[0].comments[1], comments[1]);
-    assert.equal(actualThreads[0].patchNum, undefined);
-    assert.equal(actualThreads[0].lineNum, 1);
+      assert.equal(threads.length, 1);
+      assert.equal(threads[0].diffSide, diffSide);
+      assert.isTrue(threads[0].isOnParent);
+      assert.equal(threads[0].range, undefined);
+      assert.equal(threads[0].patchNum, 2);
 
-    assert.equal(actualThreads[1].commentSide, 'left');
-    assert.equal(actualThreads[1].comments.length, 1);
-    assert.deepEqual(actualThreads[1].comments[0], comments[2]);
-    assert.equal(actualThreads[1].patchNum, undefined);
-    assert.equal(actualThreads[1].lineNum, undefined);
-  });
-
-  test('_createThreads inherits patchNum and range', () => {
-    const comments = [{
-      id: 'betsys_confession',
-      message: 'i like you, jack',
-      updated: '2015-12-24 15:00:10.396000000',
-      range: {
+      // Try to fetch a thread with a different range.
+      const range = {
         start_line: 1,
         start_character: 1,
         end_line: 1,
-        end_character: 2,
-      },
-      patch_set: 5,
-      __commentSide: 'left',
-      line: 1,
-    }];
+        end_character: 3,
+      };
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 3,
+      };
 
-    const expectedThreads = [
-      {
-        commentSide: 'left',
-        comments: [{
-          id: 'betsys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:10.396000000',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 1,
-            end_character: 2,
-          },
-          patch_set: 5,
-          __commentSide: 'left',
-          line: 1,
-        }],
-        patchNum: 5,
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 1,
-          end_character: 2,
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          lineNum: 1,
+          side: diffSide,
+          path: '/p',
+          range,
         },
-        lineNum: 1,
-        isOnParent: false,
-      },
-    ];
+      }));
 
-    assert.deepEqual(
-        element._createThreads(comments),
-        expectedThreads);
-  });
+      threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
 
-  test('_createThreads does not thread unrelated comments at same location',
-      () => {
-        const comments = [
-          {
-            id: 'sallys_confession',
-            message: 'i like you, jack',
-            updated: '2015-12-23 15:00:20.396000000',
-            __commentSide: 'left',
-          }, {
-            id: 'jacks_reply',
-            message: 'i like you, too',
-            updated: '2015-12-24 15:01:20.396000000',
-            __commentSide: 'left',
-          },
-        ];
-        assert.equal(element._createThreads(comments).length, 2);
-      });
+      assert.equal(threads.length, 2);
+      assert.equal(threads[1].diffSide, diffSide);
+      assert.isTrue(threads[0].isOnParent);
+      assert.equal(threads[1].range, range);
+      assert.equal(threads[1].patchNum, 3);
+    });
 
-  test('_createThreads derives isOnParent using  side from first comment',
-      () => {
-        const comments = [
-          {
-            id: 'sallys_confession',
-            message: 'i like you, jack',
-            updated: '2015-12-23 15:00:20.396000000',
-            __commentSide: 'left',
-          }, {
-            id: 'jacks_reply',
-            message: 'i like you, too',
-            updated: '2015-12-24 15:01:20.396000000',
-            __commentSide: 'left',
-            in_reply_to: 'sallys_confession',
-          },
-        ];
+    test('should not be on parent if on the right', () => {
+      element.patchRange = {
+        basePatchNum: 2,
+        patchNum: 3,
+      };
 
-        assert.equal(element._createThreads(comments)[0].isOnParent, false);
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: Side.RIGHT,
+        },
+      }));
 
-        comments[0].side = 'REVISION';
-        assert.equal(element._createThreads(comments)[0].isOnParent, false);
+      const thread = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread')[0];
 
-        comments[0].side = 'PARENT';
-        assert.equal(element._createThreads(comments)[0].isOnParent, true);
-      });
+      assert.isFalse(thread.isOnParent);
+    });
 
-  test('_getOrCreateThread', () => {
-    const commentSide = 'left';
+    test('should be on parent if right and base is PARENT', () => {
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 3,
+      };
 
-    assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, false));
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: Side.LEFT,
+        },
+      }));
 
-    let threads = dom(element.$.diff)
-        .queryDistributedElements('gr-comment-thread');
+      const thread = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread')[0];
 
-    assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
-    assert.equal(threads[0].range, undefined);
-    assert.equal(threads[0].isOnParent, false);
-    assert.equal(threads[0].patchNum, 2);
+      assert.isTrue(thread.isOnParent);
+    });
 
-    // Try to fetch a thread with a different range.
-    const range = {
-      start_line: 1,
-      start_character: 1,
-      end_line: 1,
-      end_character: 3,
-    };
+    test('should be on parent if right and base negative', () => {
+      element.patchRange = {
+        basePatchNum: -2, // merge parents have negative numbers
+        patchNum: 3,
+      };
 
-    assert.isOk(element._getOrCreateThread(
-        '3', 1, commentSide, range, true));
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: Side.LEFT,
+        },
+      }));
 
-    threads = dom(element.$.diff)
-        .queryDistributedElements('gr-comment-thread');
+      const thread = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread')[0];
 
-    assert.equal(threads.length, 2);
-    assert.equal(threads[1].commentSide, commentSide);
-    assert.equal(threads[1].range, range);
-    assert.equal(threads[1].isOnParent, true);
-    assert.equal(threads[1].patchNum, 3);
-  });
+      assert.isTrue(thread.isOnParent);
+    });
 
-  test('thread should use old file path if first created' +
-   'on patch set (left) before renaming', () => {
-    const commentSide = 'left';
-    element.file = {basePath: 'file_renamed.txt', path: element.path};
+    test('should not be on parent otherwise', () => {
+      element.patchRange = {
+        basePatchNum: 2, // merge parents have negative numbers
+        patchNum: 3,
+      };
 
-    assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, /* isOnParent= */ false));
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: Side.LEFT,
+        },
+      }));
 
-    const threads = dom(element.$.diff)
-        .queryDistributedElements('gr-comment-thread');
+      const thread = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread')[0];
 
-    assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
-    assert.equal(threads[0].path, element.file.basePath);
-  });
+      assert.isFalse(thread.isOnParent);
+    });
 
-  test('thread should use new file path if first created' +
-   'on patch set (right) after renaming', () => {
-    const commentSide = 'right';
-    element.file = {basePath: 'file_renamed.txt', path: element.path};
+    test('thread should use old file path if first created ' +
+    'on patch set (left) before renaming', () => {
+      const diffSide = Side.LEFT;
+      element.patchRange = {
+        basePatchNum: 2,
+        patchNum: 3,
+      };
+      element.file = {basePath: 'file_renamed.txt', path: element.path};
 
-    assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, /* isOnParent= */ false));
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: diffSide,
+          path: '/p',
+        },
+      }));
 
-    const threads = dom(element.$.diff)
-        .queryDistributedElements('gr-comment-thread');
+      const threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
 
-    assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
-    assert.equal(threads[0].path, element.file.path);
-  });
+      assert.equal(threads.length, 1);
+      assert.equal(threads[0].diffSide, diffSide);
+      assert.equal(threads[0].path, element.file.basePath);
+    });
 
-  test('thread should use new file path if first created' +
-   'on patch set (left) but is base', () => {
-    const commentSide = 'left';
-    element.file = {basePath: 'file_renamed.txt', path: element.path};
+    test('thread should use new file path if first created' +
+    'on patch set (right) after renaming', () => {
+      const diffSide = Side.RIGHT;
+      element.patchRange = {
+        basePatchNum: 2,
+        patchNum: 3,
+      };
+      element.file = {basePath: 'file_renamed.txt', path: element.path};
 
-    assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, /* isOnParent= */ true));
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: diffSide,
+          path: '/p',
+        },
+      }));
 
-    const threads = dom(element.$.diff)
-        .queryDistributedElements('gr-comment-thread');
+      const threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
 
-    assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
-    assert.equal(threads[0].path, element.file.path);
+      assert.equal(threads.length, 1);
+      assert.equal(threads[0].diffSide, diffSide);
+      assert.equal(threads[0].path, element.file.path);
+    });
+
+    test('thread should use new file path if first created' +
+    'on patch set (left) but is base', () => {
+      const diffSide = Side.LEFT;
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 3,
+      };
+      element.file = {basePath: 'file_renamed.txt', path: element.path};
+
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: diffSide,
+          path: '/p',
+        },
+      }));
+
+      const threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
+
+      assert.equal(threads.length, 1);
+      assert.equal(threads[0].diffSide, diffSide);
+      assert.equal(threads[0].path, element.file.path);
+    });
+
+    test('cannot create thread on an edit', () => {
+      const alertSpy = sinon.spy();
+      element.addEventListener('show-alert', alertSpy);
+
+      const diffSide = Side.LEFT;
+      element.patchRange = {
+        basePatchNum: EditPatchSetNum,
+        patchNum: 3,
+      };
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: diffSide,
+          path: '/p',
+        },
+      }));
+
+      const threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
+      assert.equal(threads.length, 0);
+      assert.isTrue(alertSpy.called);
+    });
+
+    test('cannot create thread on an edit base', () => {
+      const alertSpy = sinon.spy();
+      element.addEventListener('show-alert', alertSpy);
+
+      const diffSide = Side.LEFT;
+      element.patchRange = {
+        basePatchNum: ParentPatchSetNum,
+        patchNum: EditPatchSetNum,
+      };
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: diffSide,
+          path: '/p',
+        },
+      }));
+
+      const threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
+      assert.equal(threads.length, 0);
+      assert.isTrue(alertSpy.called);
+    });
   });
 
   test('_filterThreadElsForLocation with no threads', () => {
@@ -1390,23 +1233,21 @@
 
     const l3 = document.createElement('div');
     l3.setAttribute('line-num', 3);
-    l3.setAttribute('comment-side', 'left');
+    l3.setAttribute('diff-side', Side.LEFT);
 
     const l5 = document.createElement('div');
     l5.setAttribute('line-num', 5);
-    l5.setAttribute('comment-side', 'left');
+    l5.setAttribute('diff-side', Side.LEFT);
 
     const r3 = document.createElement('div');
     r3.setAttribute('line-num', 3);
-    r3.setAttribute('comment-side', 'right');
+    r3.setAttribute('diff-side', Side.RIGHT);
 
     const r5 = document.createElement('div');
     r5.setAttribute('line-num', 5);
-    r5.setAttribute('comment-side', 'right');
+    r5.setAttribute('diff-side', Side.RIGHT);
 
     const threadEls = [l3, l5, r3, r5];
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
-        [l3, r5]);
     assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
         Side.LEFT), [l3]);
     assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
@@ -1417,18 +1258,14 @@
     const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
 
     const l = document.createElement('div');
-    l.setAttribute('comment-side', 'left');
+    l.setAttribute('diff-side', Side.LEFT);
     l.setAttribute('line-num', 'FILE');
 
     const r = document.createElement('div');
-    r.setAttribute('comment-side', 'right');
+    r.setAttribute('diff-side', Side.RIGHT);
     r.setAttribute('line-num', 'FILE');
 
     const threadEls = [l, r];
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
-        [l, r]);
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        Side.BOTH), [l, r]);
     assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
         Side.LEFT), [l]);
     assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
@@ -1451,9 +1288,10 @@
       element.path = 'some/path';
     });
 
-    test('gr-diff-host provides syntax highlighting layer to gr-diff', () => {
-      element.reload();
-      assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
+    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);
     });
 
     test('rendering normal-sized diff does not disable syntax', () => {
@@ -1462,7 +1300,7 @@
           a: ['foo'],
         }],
       };
-      assert.isTrue(element.$.syntaxLayer.enabled);
+      assert.isTrue(element.syntaxLayer.enabled);
     });
 
     test('rendering large diff disables syntax', () => {
@@ -1472,19 +1310,17 @@
           a: [new Array(501).join('*')],
         }],
       };
-      assert.isFalse(element.$.syntaxLayer.enabled);
+      assert.isFalse(element.syntaxLayer.enabled);
     });
 
     test('starts syntax layer processing on render event', async () => {
-      sinon.stub(element.$.syntaxLayer, 'process')
+      sinon.stub(element.syntaxLayer, 'process')
           .returns(Promise.resolve());
-      sinon.stub(element.$.restAPI, 'getDiff').returns(
-          Promise.resolve({content: []}));
-      element.reload();
-      await flush();
+      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
+      await element.reload();
       element.dispatchEvent(
           new CustomEvent('render', {bubbles: true, composed: true}));
-      assert.isTrue(element.$.syntaxLayer.process.called);
+      assert.isTrue(element.syntaxLayer.process.called);
     });
   });
 
@@ -1506,13 +1342,14 @@
       element.prefs = prefs;
     });
 
-    test('gr-diff-host provides syntax highlighting layer', () => {
-      element.reload();
-      assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
+    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);
     });
 
     test('syntax layer should be disabled', () => {
-      assert.isFalse(element.$.syntaxLayer.enabled);
+      assert.isFalse(element.syntaxLayer.enabled);
     });
 
     test('still disabled for large diff', () => {
@@ -1522,42 +1359,45 @@
           a: [new Array(501).join('*')],
         }],
       };
-      assert.isFalse(element.$.syntaxLayer.enabled);
+      assert.isFalse(element.syntaxLayer.enabled);
     });
   });
 
   suite('coverage layer', () => {
     let notifyStub;
-    setup(() => {
+    let coverageProviderStub;
+    const exampleRanges = [
+      {
+        type: CoverageType.COVERED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 1,
+          end_line: 2,
+        },
+      },
+      {
+        type: CoverageType.NOT_COVERED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 3,
+          end_line: 4,
+        },
+      },
+    ];
+
+    setup(async () => {
       notifyStub = sinon.stub();
-      stub('gr-js-api-interface', {
-        getCoverageAnnotationApis() {
-          return Promise.resolve([{
+      coverageProviderStub = sinon.stub().returns(
+          Promise.resolve(exampleRanges));
+
+      element = basicFixture.instantiate();
+      sinon.stub(element.jsAPI, 'getCoverageAnnotationApis').returns(
+          Promise.resolve([{
             notify: notifyStub,
             getCoverageProvider() {
-              return () => Promise.resolve([
-                {
-                  type: 'COVERED',
-                  side: 'right',
-                  code_range: {
-                    start_line: 1,
-                    end_line: 2,
-                  },
-                },
-                {
-                  type: 'NOT_COVERED',
-                  side: 'right',
-                  code_range: {
-                    start_line: 3,
-                    end_line: 4,
-                  },
-                },
-              ]);
+              return coverageProviderStub;
             },
-          }]);
-        },
-      });
-      element = basicFixture.instantiate();
+          }]));
       element.changeNum = 123;
       element.change = createChange();
       element.path = 'some/path';
@@ -1574,23 +1414,42 @@
       };
       element.patchRange = {};
       element.prefs = prefs;
+      stubRestApi('getDiff').returns(Promise.resolve(element.diff));
+      await flush();
     });
 
-    test('getCoverageAnnotationApis should be called', done => {
-      element.reload();
-      flush(() => {
-        assert.isTrue(element.$.jsAPI.getCoverageAnnotationApis.calledOnce);
-        done();
-      });
+    test('getCoverageAnnotationApis should be called', async () => {
+      await element.reload();
+      assert.isTrue(element.jsAPI.getCoverageAnnotationApis.calledOnce);
     });
 
-    test('coverageRangeChanged should be called', done => {
-      element.reload();
-      flush(() => {
-        assert.equal(notifyStub.callCount, 2);
-        done();
-      });
+    test('coverageRangeChanged should be called', async () => {
+      await element.reload();
+      assert.equal(notifyStub.callCount, 2);
+      assert.isTrue(notifyStub.calledWithExactly(
+          'some/path', 1, 2, Side.RIGHT));
+      assert.isTrue(notifyStub.calledWithExactly(
+          'some/path', 3, 4, Side.RIGHT));
     });
+
+    test('provider is called with appropriate params', async () => {
+      element.patchRange.basePatchNum = 1;
+      element.patchRange.patchNum = 3;
+
+      await element.reload();
+      assert.isTrue(coverageProviderStub.calledWithExactly(
+          123, 'some/path', 1, 3, element.change));
+    });
+
+    test('provider is called with appropriate params - special patchset values',
+        async () => {
+          element.patchRange.basePatchNum = 'PARENT';
+          element.patchRange.patchNum = 'invalid';
+
+          await element.reload();
+          assert.isTrue(coverageProviderStub.calledWithExactly(
+              123, 'some/path', undefined, undefined, element.change));
+        });
   });
 
   suite('trailing newlines', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
new file mode 100644
index 0000000..77fe299
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -0,0 +1,594 @@
+/**
+ * @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-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 {Dimensions, fitToFrame, FrameConstrainer, Point, Rect} from './util';
+
+const DRAG_DEAD_ZONE_PIXELS = 5;
+
+/**
+ * This components allows the user to rapidly switch between two given images
+ * rendered in the same location, to make subtle differences more noticeable.
+ * Images can be magnified to compare details.
+ */
+@customElement('gr-image-viewer')
+export class GrImageViewer extends LitElement {
+  // URL for the image to use as base.
+  @property({type: String}) baseUrl = '';
+
+  // URL for the image to use as revision.
+  @property({type: String}) revisionUrl = '';
+
+  @internalProperty() protected baseSelected = true;
+
+  @internalProperty() protected scaledSelected = true;
+
+  @internalProperty() protected followMouse = false;
+
+  @internalProperty() protected scale = 1;
+
+  @internalProperty() protected checkerboardSelected = true;
+
+  @internalProperty() protected zoomedImageStyle: StyleInfo = {};
+
+  @query('.imageArea') protected imageArea!: HTMLDivElement;
+
+  @query('gr-zoomed-image') protected zoomedImage!: Element;
+
+  @query('#source-image') protected sourceImage!: HTMLImageElement;
+
+  private imageSize: Dimensions = {width: 0, height: 0};
+
+  @internalProperty()
+  protected magnifierSize: Dimensions = {width: 0, height: 0};
+
+  @internalProperty()
+  protected magnifierFrame: Rect = {
+    origin: {x: 0, y: 0},
+    dimensions: {width: 0, height: 0},
+  };
+
+  @internalProperty()
+  protected overviewFrame: Rect = {
+    origin: {x: 0, y: 0},
+    dimensions: {width: 0, height: 0},
+  };
+
+  protected readonly zoomLevels: Array<'fit' | number> = [
+    'fit',
+    1,
+    1.25,
+    1.5,
+    1.75,
+    2,
+  ];
+
+  @internalProperty() protected grabbing = false;
+
+  private ownsMouseDown = false;
+
+  private centerOnDown: Point = {x: 0, y: 0};
+
+  private pointerOnDown: Point = {x: 0, y: 0};
+
+  private readonly frameConstrainer = new FrameConstrainer();
+
+  private readonly resizeObserver = new ResizeObserver(
+    (entries: ResizeObserverEntry[]) => {
+      for (const entry of entries) {
+        if (entry.target === this.imageArea) {
+          this.magnifierSize = {
+            width: entry.contentRect.width,
+            height: entry.contentRect.height,
+          };
+        }
+      }
+    }
+  );
+
+  static styles = css`
+    :host {
+      display: flex;
+      width: 100%;
+      height: 100%;
+      box-sizing: border-box;
+      font-size: var(--font-size-normal);
+      --image-border-width: 2px;
+    }
+    .imageArea {
+      box-sizing: border-box;
+      flex-grow: 1;
+      overflow: hidden;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      margin: var(--spacing-m);
+      padding: var(--image-border-width);
+      max-height: 100%;
+    }
+    #spacer {
+      visibility: hidden;
+    }
+    gr-zoomed-image {
+      border: var(--image-border-width) solid;
+      margin: calc(-1 * var(--image-border-width));
+      box-sizing: content-box;
+      position: absolute;
+      overflow: hidden;
+      cursor: pointer;
+    }
+    gr-zoomed-image.base {
+      border-color: var(--base-image-border-color, rgb(255, 205, 210));
+    }
+    gr-zoomed-image.revision {
+      border-color: var(--revision-image-border-color, rgb(170, 242, 170));
+    }
+    .checkerboard {
+      --square-size: var(--checkerboard-square-size, 10px);
+      --square-color: var(--checkerboard-square-color, #808080);
+      background-color: var(--checkerboard-background-color, #aaaaaa);
+      background-image: linear-gradient(
+          45deg,
+          var(--square-color) 25%,
+          transparent 25%
+        ),
+        linear-gradient(-45deg, var(--square-color) 25%, transparent 25%),
+        linear-gradient(45deg, transparent 75%, var(--square-color) 75%),
+        linear-gradient(-45deg, transparent 75%, var(--square-color) 75%);
+      background-size: calc(var(--square-size) * 2) calc(var(--square-size) * 2);
+      background-position: 0 0, 0 var(--square-size),
+        var(--square-size) calc(-1 * var(--square-size)),
+        calc(-1 * var(--square-size)) 0;
+    }
+    .controls {
+      flex-grow: 0;
+      display: flex;
+      flex-direction: column;
+      align-self: flex-start;
+      margin: var(--spacing-m);
+      padding-bottom: var(--spacing-xl);
+    }
+    paper-button {
+      padding: var(--spacing-m);
+      font: var(--image-diff-button-font);
+      text-transform: var(--image-diff-button-text-transform, uppercase);
+    }
+    paper-button[unelevated] {
+      color: var(--primary-button-text-color);
+      background-color: var(--primary-button-background-color);
+    }
+    paper-button[outlined] {
+      color: var(--primary-button-background-color);
+      border-color: var(--primary-button-background-color);
+    }
+    #version-switcher {
+      display: flex;
+      margin: var(--spacing-xl);
+    }
+    #version-switcher paper-button {
+      flex-basis: 0;
+      flex-grow: 1;
+      margin: 0;
+    }
+    #version-explanation {
+      color: var(--deemphasized-text-color);
+      text-align: center;
+      margin: var(--spacing-xl);
+    }
+    gr-overview-image {
+      min-width: 200px;
+      min-height: 150px;
+    }
+    #zoom-control {
+      margin: 0 var(--spacing-xl);
+    }
+    #follow-mouse {
+      margin: var(--spacing-m) var(--spacing-xl) 0;
+    }
+  `;
+
+  render() {
+    const src = this.baseSelected ? this.baseUrl : this.revisionUrl;
+
+    const sourceImage = html`
+      <img
+        id="source-image"
+        src="${src}"
+        class="${classMap({
+          checkerboard: this.checkerboardSelected,
+        })}"
+        @load="${this.updateSizes}"
+      />
+    `;
+
+    const versionExplanation = html`
+      <div id="version-explanation">
+        This file is being ${this.revisionUrl ? 'added' : 'deleted'}.
+      </div>
+    `;
+
+    // This uses the unelevated and outlined attributes from mwc-button with
+    // manual styling, for a more seamless transition later.
+    const versionToggle = html`
+      <div id="version-switcher">
+        <paper-button
+          class="left"
+          ?unelevated=${this.baseSelected}
+          ?outlined=${!this.baseSelected}
+          @click="${this.selectBase}"
+        >
+          Base
+        </paper-button>
+        <paper-button
+          class="right"
+          ?unelevated=${!this.baseSelected}
+          ?outlined=${this.baseSelected}
+          @click="${this.selectRevision}"
+        >
+          Revision
+        </paper-button>
+      </div>
+    `;
+
+    const versionSwitcher = html`
+      ${this.baseUrl && this.revisionUrl ? versionToggle : versionExplanation}
+    `;
+
+    const overviewImage = html`
+      <gr-overview-image
+        .frameRect="${this.overviewFrame}"
+        @center-updated="${this.onOverviewCenterUpdated}"
+      >
+        <img src="${src}" class="checkerboard" />
+      </gr-overview-image>
+    `;
+
+    const zoomControl = html`
+      <paper-dropdown-menu id="zoom-control" label="Zoom">
+        <paper-listbox
+          slot="dropdown-content"
+          selected="fit"
+          attr-for-selected="value"
+          @selected-changed="${this.zoomControlChanged}"
+        >
+          ${this.zoomLevels.map(
+            zoomLevel => html`
+              <paper-item value="${zoomLevel}">
+                ${zoomLevel === 'fit' ? 'Fit' : `${zoomLevel * 100}%`}
+              </paper-item>
+            `
+          )}
+        </paper-listbox>
+      </paper-dropdown-menu>
+    `;
+
+    const followMouse = html`
+      <paper-checkbox
+        id="follow-mouse"
+        ?checked="${this.followMouse}"
+        @change="${this.followMouseChanged}"
+      >
+        Magnifier follows mouse
+      </paper-checkbox>
+    `;
+
+    /*
+     * We want the content to fill the available space until it can display
+     * without being cropped, the maximum of which will be determined by
+     * (max-)width and (max-)height constraints on the host element; but we
+     * are also limiting the displayed content to the measured dimensions of
+     * the host element without overflow, so we need something else to take up
+     * the requested space unconditionally.
+     */
+    const spacerScale = Math.max(this.scale, 1);
+    const spacerWidth = this.imageSize.width * spacerScale;
+    const spacerHeight = this.imageSize.height * spacerScale;
+    const spacer = html`
+      <div
+        id="spacer"
+        style="${styleMap({
+          width: `${spacerWidth}px`,
+          height: `${spacerHeight}px`,
+        })}"
+      ></div>
+    `;
+
+    // To pass CSS mixins for @apply to Polymer components, they need to be
+    // wrapped in a <custom-style>.
+    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);
+              }
+            }
+          }
+          paper-item:hover {
+            background-color: var(--hover-background-color);
+          }
+        </style>
+      </custom-style>
+    `;
+
+    return html`
+      ${customStyle}
+      <div class="imageArea" @mousemove="${this.mousemoveMagnifier}">
+        <gr-zoomed-image
+          class="${classMap({
+            base: this.baseSelected,
+            revision: !this.baseSelected,
+          })}"
+          style="${styleMap({
+            ...this.zoomedImageStyle,
+            cursor: this.grabbing ? 'grabbing' : 'pointer',
+          })}"
+          .scale="${this.scale}"
+          .frameRect="${this.magnifierFrame}"
+          @mousedown="${this.mousedownMagnifier}"
+          @mouseup="${this.mouseupMagnifier}"
+          @mousemove="${this.mousemoveMagnifier}"
+          @mouseleave="${this.mouseleaveMagnifier}"
+          @dragstart="${this.dragstartMagnifier}"
+        >
+          ${sourceImage}
+        </gr-zoomed-image>
+        ${spacer}
+      </div>
+
+      <paper-card class="controls">
+        ${versionSwitcher} ${overviewImage} ${zoomControl}
+        ${!this.scaledSelected ? followMouse : ''}
+      </paper-card>
+    `;
+  }
+
+  firstUpdated() {
+    this.resizeObserver.observe(this.imageArea, {box: 'content-box'});
+  }
+
+  // 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) {
+    if (!this.baseUrl) this.baseSelected = false;
+    if (!this.revisionUrl) this.baseSelected = true;
+    this.updateSizes();
+    super.update(changedProperties);
+  }
+
+  updated(changedProperties: PropertyValues) {
+    if (
+      (changedProperties.has('baseUrl') && this.baseSelected) ||
+      (changedProperties.has('revisionUrl') && !this.baseSelected)
+    ) {
+      this.frameConstrainer.requestCenter({x: 0, y: 0});
+    }
+  }
+
+  selectBase() {
+    if (!this.baseUrl) return;
+    this.baseSelected = true;
+  }
+
+  selectRevision() {
+    if (!this.revisionUrl) return;
+    this.baseSelected = false;
+  }
+
+  toggleImage() {
+    if (this.baseUrl && this.revisionUrl) {
+      this.baseSelected = !this.baseSelected;
+    }
+  }
+
+  zoomControlChanged(event: CustomEvent) {
+    const value = event.detail.value;
+    if (!value) return;
+    if (value === 'fit') {
+      this.scaledSelected = true;
+    }
+    if (value > 0) {
+      this.scaledSelected = false;
+      this.scale = value;
+    }
+    this.updateSizes();
+  }
+
+  followMouseChanged() {
+    this.followMouse = !this.followMouse;
+  }
+
+  mousedownMagnifier(event: MouseEvent) {
+    if (event.buttons === 1) {
+      this.ownsMouseDown = true;
+      this.centerOnDown = this.frameConstrainer.getCenter();
+      this.pointerOnDown = {
+        x: event.clientX,
+        y: event.clientY,
+      };
+    }
+  }
+
+  mouseupMagnifier(event: MouseEvent) {
+    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) {
+      this.toggleImage();
+    }
+    this.grabbing = false;
+    this.ownsMouseDown = false;
+  }
+
+  mousemoveMagnifier(event: MouseEvent) {
+    if (event.buttons === 1 && this.ownsMouseDown) {
+      this.handleMagnifierDrag(event);
+      return;
+    }
+    if (this.followMouse) {
+      this.handleFollowMouse(event);
+      return;
+    }
+  }
+
+  private handleMagnifierDrag(event: MouseEvent) {
+    this.grabbing = true;
+    const offsetX = event.clientX - this.pointerOnDown.x;
+    const offsetY = event.clientY - this.pointerOnDown.y;
+    this.frameConstrainer.requestCenter({
+      x: this.centerOnDown.x - offsetX / this.scale,
+      y: this.centerOnDown.y - offsetY / this.scale,
+    });
+    this.updateFrames();
+  }
+
+  private handleFollowMouse(event: MouseEvent) {
+    const rect = this.imageArea!.getBoundingClientRect();
+    const offsetX = event.clientX - rect.left;
+    const offsetY = event.clientY - rect.top;
+    const fractionX = offsetX / rect.width;
+    const fractionY = offsetY / rect.height;
+    this.frameConstrainer.requestCenter({
+      x: this.imageSize.width * fractionX,
+      y: this.imageSize.height * fractionY,
+    });
+    this.updateFrames();
+  }
+
+  mouseleaveMagnifier() {
+    this.grabbing = false;
+    this.ownsMouseDown = false;
+  }
+
+  dragstartMagnifier(event: DragEvent) {
+    event.preventDefault();
+  }
+
+  onOverviewCenterUpdated(event: CustomEvent) {
+    this.frameConstrainer.requestCenter({
+      x: event.detail.x as number,
+      y: event.detail.y as number,
+    });
+    this.updateFrames();
+  }
+
+  updateFrames() {
+    this.magnifierFrame = this.frameConstrainer.getUnscaledFrame();
+    this.overviewFrame = this.frameConstrainer.getScaledFrame();
+  }
+
+  updateSizes() {
+    if (!this.sourceImage || !this.sourceImage.complete) return;
+
+    this.imageSize = {
+      width: this.sourceImage.naturalWidth || 0,
+      height: this.sourceImage.naturalHeight || 0,
+    };
+
+    this.frameConstrainer.setBounds(this.imageSize);
+
+    if (this.scaledSelected) {
+      const fittedImage = fitToFrame(this.imageSize, this.magnifierSize);
+      this.scale = Math.min(fittedImage.scale, 1);
+    }
+
+    this.frameConstrainer.setScale(this.scale);
+
+    const scaledImageSize = {
+      width: this.imageSize.width * this.scale,
+      height: this.imageSize.height * this.scale,
+    };
+
+    const width = Math.min(this.magnifierSize.width, scaledImageSize.width);
+    const height = Math.min(this.magnifierSize.height, scaledImageSize.height);
+
+    this.frameConstrainer.setFrameSize({width, height});
+
+    this.updateFrames();
+
+    this.zoomedImageStyle = {
+      ...this.zoomedImageStyle,
+      width: `${width}px`,
+      height: `${height}px`,
+    };
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-image-viewer': GrImageViewer;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
new file mode 100644
index 0000000..55f83d6
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -0,0 +1,306 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+  css,
+  customElement,
+  html,
+  internalProperty,
+  LitElement,
+  property,
+  PropertyValues,
+  query,
+} from 'lit-element';
+import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
+
+import {Dimensions, fitToFrame, Point, Rect} from './util';
+
+/**
+ * Displays a scaled-down version of an image with a draggable frame for
+ * choosing a portion of the image to be magnified by other components.
+ *
+ * Slotted content can be arbitrary elements, but should be limited to images or
+ * stacks of image-like elements (e.g. for overlays) with limited interactivity,
+ * to prevent confusion, as the component only captures a limited set of events.
+ * Slotted content is scaled to fit the bounds of the component, with
+ * letterboxing if aspect ratios differ. For slotted content smaller than the
+ * component, it will cap the scale at 1x and also apply letterboxing.
+ */
+@customElement('gr-overview-image')
+export class GrOverviewImage extends LitElement {
+  @property({type: Object})
+  frameRect: Rect = {origin: {x: 0, y: 0}, dimensions: {width: 0, height: 0}};
+
+  @internalProperty() protected contentStyle: StyleInfo = {};
+
+  @internalProperty() protected contentTransformStyle: StyleInfo = {};
+
+  @internalProperty() protected frameStyle: StyleInfo = {};
+
+  @internalProperty() protected overlayStyle: StyleInfo = {};
+
+  @internalProperty() protected dragging = false;
+
+  @query('.content-box') protected contentBox!: HTMLDivElement;
+
+  @query('.content') protected content!: HTMLDivElement;
+
+  @query('.content-transform') protected contentTransform!: HTMLDivElement;
+
+  @query('.frame') protected frame!: HTMLDivElement;
+
+  private contentBounds: Dimensions = {width: 0, height: 0};
+
+  private imageBounds: Dimensions = {width: 0, height: 0};
+
+  private scale = 1;
+
+  // When grabbing the frame to drag it around, this stores the offset of the
+  // cursor from the center of the frame at the start of the drag.
+  private grabOffset: Point = {x: 0, y: 0};
+
+  private readonly resizeObserver = new ResizeObserver(
+    (entries: ResizeObserverEntry[]) => {
+      for (const entry of entries) {
+        if (entry.target === this.contentBox) {
+          this.contentBounds = {
+            width: entry.contentRect.width,
+            height: entry.contentRect.height,
+          };
+        }
+        if (entry.target === this.contentTransform) {
+          this.imageBounds = {
+            width: entry.contentRect.width,
+            height: entry.contentRect.height,
+          };
+        }
+        this.updateScale();
+      }
+    }
+  );
+
+  static styles = css`
+    :host {
+      --background-color: var(--overview-image-background-color, #000);
+      --frame-color: var(--overview-image-frame-color, #f00);
+      display: flex;
+    }
+    * {
+      box-sizing: border-box;
+    }
+    ::slotted(*) {
+      display: block;
+    }
+    .content-box {
+      border: 1px solid var(--background-color);
+      background-color: var(--background-color);
+      width: 100%;
+      position: relative;
+    }
+    .content {
+      position: absolute;
+      cursor: pointer;
+    }
+    .content-transform {
+      position: absolute;
+      transform-origin: top left;
+      will-change: transform;
+    }
+    .frame {
+      border: 1px solid var(--frame-color);
+      position: absolute;
+      will-change: transform;
+    }
+    .overlay {
+      position: absolute;
+      z-index: 10000;
+      cursor: grabbing;
+    }
+  `;
+
+  render() {
+    return html`
+      <div class="content-box">
+        <div
+          class="content"
+          style="${styleMap({
+            ...this.contentStyle,
+          })}"
+          @mousemove="${this.maybeDragFrame}"
+          @mousedown=${this.clickOverview}
+          @mouseup="${this.releaseFrame}"
+        >
+          <div
+            class="content-transform"
+            style="${styleMap(this.contentTransformStyle)}"
+          >
+            <slot></slot>
+          </div>
+          <div
+            class="frame"
+            style="${styleMap({
+              ...this.frameStyle,
+              cursor: this.dragging ? 'grabbing' : 'grab',
+            })}"
+            @mousedown="${this.grabFrame}"
+          ></div>
+        </div>
+        <div
+          class="overlay"
+          style="${styleMap({
+            ...this.overlayStyle,
+            display: this.dragging ? 'block' : 'none',
+          })}"
+          @mousemove="${this.overlayMouseMove}"
+          @mouseleave="${this.releaseFrame}"
+          @mouseup="${this.releaseFrame}"
+        ></div>
+      </div>
+    `;
+  }
+
+  firstUpdated() {
+    this.resizeObserver.observe(this.contentBox);
+    this.resizeObserver.observe(this.contentTransform);
+  }
+
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('frameRect')) {
+      this.updateFrameStyle();
+    }
+  }
+
+  clickOverview(event: MouseEvent) {
+    if (event.buttons !== 1) return;
+    event.preventDefault();
+
+    this.updateOverlaySize();
+
+    this.dragging = true;
+    const rect = this.content.getBoundingClientRect();
+    this.notifyNewCenter({
+      x: (event.clientX - rect.left) / this.scale,
+      y: (event.clientY - rect.top) / this.scale,
+    });
+  }
+
+  grabFrame(event: MouseEvent) {
+    if (event.buttons !== 1) return;
+    event.preventDefault();
+    // Do not bubble up into clickOverview().
+    event.stopPropagation();
+
+    this.updateOverlaySize();
+
+    this.dragging = true;
+    const rect = this.frame.getBoundingClientRect();
+    const frameCenterX = rect.x + rect.width / 2;
+    const frameCenterY = rect.y + rect.height / 2;
+    this.grabOffset = {
+      x: event.clientX - frameCenterX,
+      y: event.clientY - frameCenterY,
+    };
+  }
+
+  maybeDragFrame(event: MouseEvent) {
+    event.preventDefault();
+    if (!this.dragging) return;
+    const rect = this.content.getBoundingClientRect();
+    const center = {
+      x: (event.clientX - rect.left - this.grabOffset.x) / this.scale,
+      y: (event.clientY - rect.top - this.grabOffset.y) / this.scale,
+    };
+    this.notifyNewCenter(center);
+  }
+
+  releaseFrame(event: MouseEvent) {
+    event.preventDefault();
+    this.dragging = false;
+    this.grabOffset = {x: 0, y: 0};
+  }
+
+  overlayMouseMove(event: MouseEvent) {
+    event.preventDefault();
+    this.maybeDragFrame(event);
+  }
+
+  private updateScale() {
+    const fitted = fitToFrame(this.imageBounds, this.contentBounds);
+    this.scale = fitted.scale;
+
+    this.contentStyle = {
+      ...this.contentStyle,
+      top: `${fitted.top}px`,
+      left: `${fitted.left}px`,
+      width: `${fitted.width}px`,
+      height: `${fitted.height}px`,
+    };
+
+    this.contentTransformStyle = {
+      transform: `scale(${this.scale})`,
+    };
+
+    this.updateFrameStyle();
+  }
+
+  private updateFrameStyle() {
+    const x = this.frameRect.origin.x * this.scale;
+    const y = this.frameRect.origin.y * this.scale;
+    const width = this.frameRect.dimensions.width * this.scale;
+    const height = this.frameRect.dimensions.height * this.scale;
+    this.frameStyle = {
+      ...this.frameStyle,
+      transform: `translate(${x}px, ${y}px)`,
+      width: `${width}px`,
+      height: `${height}px`,
+    };
+  }
+
+  private updateOverlaySize() {
+    const rect = this.contentBox.getBoundingClientRect();
+    // Create a whole-page overlay to capture mouse events, so that the drag
+    // interaction continues until the user releases the mouse button. Since
+    // innerWidth and innerHeight include scrollbars, we subtract 20 pixels each
+    // to prevent the overlay from extending offscreen under any existing
+    // scrollbar and causing the scrollbar for the other dimension to show up
+    // unnecessarily.
+    const width = window.innerWidth - 20;
+    const height = window.innerHeight - 20;
+    this.overlayStyle = {
+      ...this.overlayStyle,
+      top: `-${rect.top + 1}px`,
+      left: `-${rect.left + 1}px`,
+      width: `${width}px`,
+      height: `${height}px`,
+    };
+  }
+
+  private notifyNewCenter(center: Point) {
+    this.dispatchEvent(
+      new CustomEvent('center-updated', {
+        detail: {...center},
+        bubbles: true,
+        composed: true,
+      })
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-overview-image': GrOverviewImage;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
new file mode 100644
index 0000000..a14a9cc
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
@@ -0,0 +1,95 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+  css,
+  customElement,
+  html,
+  internalProperty,
+  LitElement,
+  property,
+  PropertyValues,
+} from 'lit-element';
+import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
+import {Rect} from './util';
+
+/**
+ * Displays its slotted content at a given scale, centered over a given point,
+ * while ensuring the content always fills the container. The content does not
+ * have to be a single image, it can be arbitrary HTML. To prevent user
+ * confusion, it should ideally be image-like, i.e. have limited or no
+ * interactivity, as the component does not prevent events or focus from
+ * reaching the slotted content.
+ */
+@customElement('gr-zoomed-image')
+export class GrZoomedImage extends LitElement {
+  @property({type: Number}) scale = 1;
+
+  @property({type: Object})
+  frameRect: Rect = {origin: {x: 0, y: 0}, dimensions: {width: 0, height: 0}};
+
+  @internalProperty() protected imageStyles: StyleInfo = {};
+
+  static styles = css`
+    :host {
+      display: block;
+    }
+    ::slotted(*) {
+      display: block;
+    }
+    #clip {
+      position: relative;
+      width: 100%;
+      height: 100%;
+      overflow: hidden;
+    }
+    #transform {
+      position: absolute;
+      transform-origin: top left;
+      will-change: transform;
+    }
+  `;
+
+  render() {
+    return html`
+      <div id="clip">
+        <div id="transform" style="${styleMap(this.imageStyles)}">
+          <slot></slot>
+        </div>
+      </div>
+    `;
+  }
+
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('scale') || changedProperties.has('frameRect')) {
+      this.updateImageStyles();
+    }
+  }
+
+  private updateImageStyles() {
+    const {x, y} = this.frameRect.origin;
+    this.imageStyles = {
+      'image-rendering': this.scale >= 1 ? 'pixelated' : 'auto',
+      transform: `translate(${-x}px, ${-y}px) scale(${this.scale})`,
+    };
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-zoomed-image': GrZoomedImage;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
new file mode 100644
index 0000000..b42eea9
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
@@ -0,0 +1,236 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface Point {
+  x: number;
+  y: number;
+}
+
+export interface Dimensions {
+  width: number;
+  height: number;
+}
+
+export interface Rect {
+  origin: Point;
+  dimensions: Dimensions;
+}
+
+export interface FittedContent {
+  top: number;
+  left: number;
+  width: number;
+  height: number;
+  scale: number;
+}
+
+function clamp(value: number, min: number, max: number) {
+  return Math.max(min, Math.min(value, max));
+}
+
+/**
+ * Fits content of the given dimensions into the given frame, maintaining the
+ * aspect ratio of the content and applying letterboxing / pillarboxing as
+ * needed.
+ */
+export function fitToFrame(
+  content: Dimensions,
+  frame: Dimensions
+): FittedContent {
+  const contentAspectRatio = content.width / content.height;
+  const frameAspectRatio = frame.width / frame.height;
+  // If the content is wider than the frame, it will be letterboxed, otherwise
+  // it will be pillarboxed. When letterboxed, content and frame width will
+  // match exactly, when pillarboxed, content and frame height will match
+  // exactly.
+  const isLetterboxed = contentAspectRatio > frameAspectRatio;
+  let width: number;
+  let height: number;
+  if (isLetterboxed) {
+    width = Math.min(frame.width, content.width);
+    height = content.height * (width / content.width);
+  } else {
+    height = Math.min(frame.height, content.height);
+    width = content.width * (height / content.height);
+  }
+  const top = (frame.height - height) / 2;
+  const left = (frame.width - width) / 2;
+  const scale = width / content.width;
+  return {top, left, width, height, scale};
+}
+
+function ensureInBounds(part: Rect, bounds: Dimensions): Rect {
+  const x =
+    part.dimensions.width <= bounds.width
+      ? clamp(part.origin.x, 0, bounds.width - part.dimensions.width)
+      : (bounds.width - part.dimensions.width) / 2;
+  const y =
+    part.dimensions.height <= bounds.height
+      ? clamp(part.origin.y, 0, bounds.height - part.dimensions.height)
+      : (bounds.height - part.dimensions.height) / 2;
+  return {origin: {x, y}, dimensions: part.dimensions};
+}
+
+/**
+ * Maintains a given frame inside given bounds, adjusting requested positions
+ * for the frame as needed. This supports the non-destructive application of a
+ * scaling factor, so that e.g. the magnification of an image can be changed
+ * easily while keeping the frame centered over the same spot. Changing bounds
+ * or frame size also keeps the frame position when possible.
+ */
+export class FrameConstrainer {
+  private center: Point = {x: 0, y: 0};
+
+  private frameSize: Dimensions = {width: 0, height: 0};
+
+  private bounds: Dimensions = {width: 0, height: 0};
+
+  private scale = 1;
+
+  private unscaledFrame: Rect = {
+    origin: {x: 0, y: 0},
+    dimensions: {width: 0, height: 0},
+  };
+
+  private scaledFrame: Rect = {
+    origin: {x: 0, y: 0},
+    dimensions: {width: 0, height: 0},
+  };
+
+  getCenter(): Point {
+    return {...this.center};
+  }
+
+  /**
+   * Returns the frame at its original size, positioned within the given bounds
+   * at the given scale; its origin will be in scaled bounds coordinates.
+   *
+   * Ex: for given bounds 100x50 and frame size 30x20, centered over (50, 25),
+   * all at 1x scale, when setting scale to 2, this will return a frame of size
+   * 30x20, centered over (100, 50), within bounds 200x100.
+   *
+   * Useful for positioning a viewport of fixed size over a magnified image.
+   */
+  getUnscaledFrame(): Rect {
+    return {
+      origin: {...this.unscaledFrame.origin},
+      dimensions: {...this.unscaledFrame.dimensions},
+    };
+  }
+
+  /**
+   * Returns the scaled down frame–a scale of 2 will result in frame dimensions
+   * being halved—position within the given bounds at 1x scale; its origin will
+   * be in unscaled bounds coordinates.
+   *
+   * Ex: for given bounds 100x50 and frame size 30x20, centered over (50, 25),
+   * all at 1x scale, when setting scale to 2, this will return a frame of size
+   * 15x10, centered over (50, 25), within bounds 100x50.
+   *
+   * Useful for highlighting the magnified portion of an image as determined by
+   * getUnscaledFrame() in an overview image of fixed size.
+   */
+  getScaledFrame(): Rect {
+    return {
+      origin: {...this.scaledFrame.origin},
+      dimensions: {...this.scaledFrame.dimensions},
+    };
+  }
+
+  /**
+   * Requests the frame to be centered over the given point, in unscaled bounds
+   * coordinates. This will keep the frame within the given bounds, also when
+   * requesting a center point fully outside the given bounds.
+   */
+  requestCenter(center: Point) {
+    this.center = {...center};
+
+    this.ensureFrameInBounds();
+  }
+
+  /**
+   * Sets the frame size, while keeping the frame within the given bounds, and
+   * maintaining the current center if possible.
+   */
+  setFrameSize(frameSize: Dimensions) {
+    if (frameSize.width <= 0 || frameSize.height <= 0) return;
+    this.frameSize = {...frameSize};
+
+    this.ensureFrameInBounds();
+  }
+
+  /**
+   * Sets the bounds, while keeping the frame within them, and maintaining the
+   * current center if possible.
+   */
+  setBounds(bounds: Dimensions) {
+    if (bounds.width <= 0 || bounds.height <= 0) return;
+    this.bounds = {...bounds};
+
+    this.ensureFrameInBounds();
+  }
+
+  /**
+   * Sets the applied scale, while keeping the frame within the given bounds,
+   * and maintaining the current center if possible (both relevant moving from
+   * a larger scale to a smaller scale).
+   */
+  setScale(scale: number) {
+    if (!scale || scale <= 0) return;
+    this.scale = scale;
+
+    this.ensureFrameInBounds();
+  }
+
+  private ensureFrameInBounds() {
+    const scaledCenter = {
+      x: this.center.x * this.scale,
+      y: this.center.y * this.scale,
+    };
+    const scaledBounds = {
+      width: this.bounds.width * this.scale,
+      height: this.bounds.height * this.scale,
+    };
+    const scaledFrameSize = {
+      width: this.frameSize.width / this.scale,
+      height: this.frameSize.height / this.scale,
+    };
+
+    const requestedUnscaledFrame = {
+      origin: {
+        x: scaledCenter.x - this.frameSize.width / 2,
+        y: scaledCenter.y - this.frameSize.height / 2,
+      },
+      dimensions: this.frameSize,
+    };
+    const requestedScaledFrame = {
+      origin: {
+        x: this.center.x - scaledFrameSize.width / 2,
+        y: this.center.y - scaledFrameSize.height / 2,
+      },
+      dimensions: scaledFrameSize,
+    };
+
+    this.unscaledFrame = ensureInBounds(requestedUnscaledFrame, scaledBounds);
+    this.scaledFrame = ensureInBounds(requestedScaledFrame, this.bounds);
+
+    this.center = {
+      x: this.scaledFrame.origin.x + this.scaledFrame.dimensions.width / 2,
+      y: this.scaledFrame.origin.y + this.scaledFrame.dimensions.height / 2,
+    };
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js
new file mode 100644
index 0000000..80cfa36
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js
@@ -0,0 +1,171 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../test/common-test-setup-karma.js';
+import {FrameConstrainer} from './util.js';
+
+suite('FrameConstrainer tests', () => {
+  let constrainer;
+
+  setup(() => {
+    constrainer = new FrameConstrainer();
+    constrainer.setBounds({width: 100, height: 100});
+    constrainer.setFrameSize({width: 50, height: 50});
+    constrainer.requestCenter({x: 50, y: 50});
+  });
+
+  suite('changing center', () => {
+    test('moves frame to requested position', () => {
+      constrainer.requestCenter({x: 30, y: 30});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 5, y: 5}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('keeps frame in bounds for top left corner', () => {
+      constrainer.requestCenter({x: 5, y: 5});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 0, y: 0}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('keeps frame in bounds for bottom right corner', () => {
+      constrainer.requestCenter({x: 95, y: 95});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center left', () => {
+      constrainer.requestCenter({x: -5, y: 50});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 0, y: 25}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center right', () => {
+      constrainer.requestCenter({x: 105, y: 50});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 50, y: 25}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center top', () => {
+      constrainer.requestCenter({x: 50, y: -5});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 25, y: 0}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center bottom', () => {
+      constrainer.requestCenter({x: 50, y: 105});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 25, y: 50}, dimensions: {width: 50, height: 50}});
+    });
+  });
+
+  suite('changing frame size', () => {
+    test('maintains center when decreased', () => {
+      constrainer.setFrameSize({width: 10, height: 10});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 45, y: 45}, dimensions: {width: 10, height: 10}});
+    });
+
+    test('maintains center when increased', () => {
+      constrainer.setFrameSize({width: 80, height: 80});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 10, y: 10}, dimensions: {width: 80, height: 80}});
+    });
+
+    test('updates center to remain in bounds when increased', () => {
+      constrainer.setFrameSize({width: 10, height: 10});
+      constrainer.requestCenter({x: 95, y: 95});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 90, y: 90}, dimensions: {width: 10, height: 10}});
+
+      constrainer.setFrameSize({width: 20, height: 20});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 80, y: 80}, dimensions: {width: 20, height: 20}});
+    });
+  });
+
+  suite('changing scale', () => {
+    suite('for unscaled frame', () => {
+      test('adjusts origin to maintain center when zooming in', () => {
+        constrainer.setScale(2);
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 75, y: 75}, dimensions: {width: 50, height: 50}});
+      });
+
+      test('adjusts origin to maintain center when zooming out', () => {
+        constrainer.setFrameSize({width: 20, height: 20});
+        constrainer.setScale(0.5);
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 15, y: 15}, dimensions: {width: 20, height: 20}});
+      });
+
+      test('keeps frame in bounds when zooming out', () => {
+        constrainer.setScale(5);
+        constrainer.requestCenter({x: 100, y: 100});
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 450, y: 450}, dimensions: {width: 50, height: 50}});
+
+        constrainer.setScale(1);
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
+      });
+    });
+
+    suite('for scaled frame', () => {
+      test('decreases frame size and maintains center when zooming in', () => {
+        constrainer.setScale(2);
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 37.5, y: 37.5}, dimensions: {width: 25, height: 25}});
+      });
+
+      test('increases frame size and maintains center when zooming out', () => {
+        constrainer.setFrameSize({width: 20, height: 20});
+        constrainer.setScale(0.5);
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 30, y: 30}, dimensions: {width: 40, height: 40}});
+      });
+
+      test('keeps frame in bounds when zooming out', () => {
+        constrainer.setScale(5);
+        constrainer.requestCenter({x: 100, y: 100});
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 90, y: 90}, dimensions: {width: 10, height: 10}});
+
+        constrainer.setScale(1);
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
+      });
+    });
+  });
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index e0333cc..b576896 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -18,27 +18,17 @@
 import '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {DiffViewMode} from '../../../constants/constants';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-mode-selector_html';
 import {customElement, property} from '@polymer/decorators';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {FixIronA11yAnnouncer} from '../../../types/types';
-
-export interface GrDiffModeSelector {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
+import {fireIronAnnounce} from '../../../utils/event-util';
 
 @customElement('gr-diff-mode-selector')
-export class GrDiffModeSelector extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffModeSelector extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -53,7 +43,11 @@
   @property({type: Boolean})
   saveOnChange = false;
 
-  attached() {
+  private readonly restApiService = appContext.restApiService;
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
     ((IronA11yAnnouncer as unknown) as FixIronA11yAnnouncer).requestAvailability();
   }
 
@@ -62,23 +56,17 @@
    */
   setMode(newMode: DiffViewMode) {
     if (this.saveOnChange && this.mode && this.mode !== newMode) {
-      this.$.restAPI.savePreferences({diff_view: newMode});
+      this.restApiService.savePreferences({diff_view: newMode});
     }
     this.mode = newMode;
-    let annoucement;
+    let announcement;
     if (this.isUnifiedSelected(newMode)) {
-      annoucement = 'Changed diff view to unified';
+      announcement = 'Changed diff view to unified';
     } else if (this.isSideBySideSelected(newMode)) {
-      annoucement = 'Changed diff view to side by side';
+      announcement = 'Changed diff view to side by side';
     }
-    if (annoucement) {
-      this.fire(
-        'iron-announce',
-        {
-          text: annoucement,
-        },
-        {bubbles: true}
-      );
+    if (announcement) {
+      fireIronAnnounce(this, announcement);
     }
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
index a5bf269..9943b58 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
@@ -52,5 +52,4 @@
   >
     <iron-icon icon="gr-icons:unified"></iron-icon>
   </gr-button>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js
index 07a1d16..f554227 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-diff-mode-selector.js';
 import {DiffViewMode} from '../../../constants/constants.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-diff-mode-selector');
 
@@ -40,7 +41,7 @@
   });
 
   test('setMode', () => {
-    const saveStub = sinon.stub(element.$.restAPI, 'savePreferences');
+    const saveStub = stubRestApi('savePreferences');
 
     // Setting the mode initially does not save prefs.
     element.saveOnChange = true;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
index c66af58..5a8c55d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
@@ -18,15 +18,13 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-diff-preferences/gr-diff-preferences';
 import '../../shared/gr-overlay/gr-overlay';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-preferences-dialog_html';
 import {customElement, property} from '@polymer/decorators';
 import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {DiffPreferencesInfo} from '../../../types/common';
+import {DiffPreferencesInfo} from '../../../types/diff';
 
 export interface GrDiffPreferencesDialog {
   $: {
@@ -37,9 +35,7 @@
   };
 }
 @customElement('gr-diff-preferences-dialog')
-export class GrDiffPreferencesDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffPreferencesDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
index ab7ab8a..d5e2a07 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
@@ -14,14 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {
   GrDiffLine,
   GrDiffLineType,
   FILE,
   Highlights,
+  LineNumber,
 } from '../gr-diff/gr-diff-line';
 import {
   GrDiffGroup,
@@ -30,8 +29,9 @@
 } from '../gr-diff/gr-diff-group';
 import {CancelablePromise, util} from '../../../scripts/util';
 import {customElement, property} from '@polymer/decorators';
-import {DiffContent} from '../../../types/common';
+import {DiffContent} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
+import {debounce, DelayedTask} from '../../../utils/async-util';
 
 const WHOLE_FILE = -1;
 
@@ -89,9 +89,7 @@
  *    the rest is not.
  */
 @customElement('gr-diff-processor')
-export class GrDiffProcessor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffProcessor extends PolymerElement {
   @property({type: Number})
   context = 3;
 
@@ -113,29 +111,30 @@
   @property({type: Boolean})
   _isScrolling?: boolean;
 
+  private resetIsScrollingTask?: DelayedTask;
+
   /** @override */
-  attached() {
-    super.attached();
-    this.listen(window, 'scroll', '_handleWindowScroll');
+  connectedCallback() {
+    super.connectedCallback();
+    window.addEventListener('scroll', this.handleWindowScroll);
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
+    this.resetIsScrollingTask?.cancel();
     this.cancel();
-    this.unlisten(window, 'scroll', '_handleWindowScroll');
+    window.removeEventListener('scroll', this.handleWindowScroll);
+    super.disconnectedCallback();
   }
 
-  _handleWindowScroll() {
+  private readonly handleWindowScroll = () => {
     this._isScrolling = true;
-    this.debounce(
-      'resetIsScrolling',
-      () => {
-        this._isScrolling = false;
-      },
+    this.resetIsScrollingTask = debounce(
+      this.resetIsScrollingTask,
+      () => (this._isScrolling = false),
       50
     );
-  }
+  };
 
   /**
    * Asynchronously process the diff chunks into groups. As it processes, it
@@ -150,7 +149,8 @@
     this.cancel();
 
     this.groups = [];
-    this.push('groups', this._makeFileComments());
+    this.push('groups', this._makeGroup('LOST'));
+    this.push('groups', this._makeGroup(FILE));
 
     // If it's a binary diff, we won't be rendering hunks of text differences
     // so finish processing.
@@ -171,7 +171,7 @@
         let currentBatch = 0;
         const nextStep = () => {
           if (this._isScrolling) {
-            this._nextStepHandle = this.async(nextStep, 100);
+            this._nextStepHandle = window.setTimeout(nextStep, 100);
             return;
           }
           // If we are done, resolve the promise.
@@ -194,7 +194,7 @@
           state.chunkIndex = stateUpdate.newChunkIndex;
           if (currentBatch >= this._asyncThreshold) {
             currentBatch = 0;
-            this._nextStepHandle = this.async(nextStep, 1);
+            this._nextStepHandle = window.setTimeout(nextStep, 1);
           } else {
             nextStep.call(this);
           }
@@ -213,7 +213,7 @@
    */
   cancel() {
     if (this._nextStepHandle !== null) {
-      this.cancelAsync(this._nextStepHandle);
+      window.clearTimeout(this._nextStepHandle);
       this._nextStepHandle = null;
     }
     if (this._processPromise) {
@@ -366,13 +366,16 @@
     const group = new GrDiffGroup(type, lines);
     group.keyLocation = !!chunk.keyLocation;
     group.dueToRebase = !!chunk.due_to_rebase;
-    group.dueToMove = !!chunk.due_to_move;
+    group.moveDetails = chunk.move_details;
     group.skip = chunk.skip;
     group.ignoredWhitespaceOnly = !!chunk.common;
     if (chunk.skip) {
       group.lineRange = {
-        left: {start: offsetLeft, end: offsetLeft + chunk.skip - 1},
-        right: {start: offsetRight, end: offsetRight + chunk.skip - 1},
+        left: {start_line: offsetLeft, end_line: offsetLeft + chunk.skip - 1},
+        right: {
+          start_line: offsetRight,
+          end_line: offsetRight + chunk.skip - 1,
+        },
       };
     }
     return group;
@@ -447,10 +450,10 @@
     return line;
   }
 
-  _makeFileComments() {
+  _makeGroup(number: LineNumber) {
     const line = new GrDiffLine(GrDiffLineType.BOTH);
-    line.beforeNumber = FILE;
-    line.afterNumber = FILE;
+    line.beforeNumber = number;
+    line.afterNumber = number;
     return new GrDiffGroup(GrDiffGroupType.BOTH, [line]);
   }
 
@@ -679,15 +682,18 @@
    */
   _breakdownChunk(chunk: DiffContent): DiffContent[] {
     let key: 'a' | 'b' | 'ab' | null = null;
-    if (chunk.a && !chunk.b) {
+    const {a, b, ab, move_details} = chunk;
+    if (a?.length && !b?.length) {
       key = 'a';
-    } else if (chunk.b && !chunk.a) {
+    } else if (b?.length && !a?.length) {
       key = 'b';
-    } else if (chunk.ab) {
+    } else if (ab?.length) {
       key = 'ab';
     }
 
-    if (!key) {
+    // Move chunks should not be divided because of move label
+    // positioned in the top of the chunk
+    if (!key || move_details) {
       return [chunk];
     }
 
@@ -697,8 +703,8 @@
       if (chunk.due_to_rebase) {
         subChunk.due_to_rebase = true;
       }
-      if (chunk.due_to_move) {
-        subChunk.due_to_move = true;
+      if (chunk.move_details) {
+        subChunk.move_details = chunk.move_details;
       }
       return subChunk;
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
index ce7a3c4..0bb5eac 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
@@ -73,7 +73,7 @@
 
       return element.process(content).then(() => {
         const groups = element.groups;
-
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
         assert.equal(groups.length, 4);
 
         let group = groups[0];
@@ -133,6 +133,7 @@
 
       return element.process(content).then(() => {
         const groups = element.groups;
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
         assert.equal(groups[0].type, GrDiffGroupType.BOTH);
         assert.equal(groups[0].lines.length, 1);
@@ -153,6 +154,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
 
@@ -185,6 +187,7 @@
         await element.process(content);
 
         const groups = element.groups;
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
         // group[0] is the file group
 
@@ -202,8 +205,8 @@
         const skipGroup = commonGroup.contextGroups[1];
         assert.equal(skipGroup.skip, 43900);
         const expectedRange = {
-          left: {start: 21, end: 43920},
-          right: {start: 21, end: 43920},
+          left: {start_line: 21, end_line: 43920},
+          right: {start_line: 21, end_line: 43920},
         };
         assert.deepEqual(skipGroup.lineRange, expectedRange);
 
@@ -231,6 +234,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
 
@@ -252,6 +256,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
           // group[1] is the "a" group
@@ -283,6 +288,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
           // group[1] is the "a" group
@@ -324,6 +330,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
           // group[1] is the "a" group
@@ -411,6 +418,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
           // group[1] is the "a" group
@@ -450,6 +458,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
           // group[1] is the "a" group
@@ -479,6 +488,7 @@
       await element.process(content);
 
       const groups = element.groups;
+      groups.shift(); // remove portedThreadsWithoutRangeGroup
 
       // group[0] is the file group
       // group[1] is the chunk with a
@@ -499,8 +509,8 @@
       const skipGroup = commonGroup.contextGroups[1];
       assert.equal(skipGroup.skip, 60);
       const expectedRange = {
-        left: {start: 22, end: 81},
-        right: {start: 21, end: 80},
+        left: {start_line: 22, end_line: 81},
+        right: {start_line: 21, end_line: 80},
       };
       assert.deepEqual(skipGroup.lineRange, expectedRange);
 
@@ -590,6 +600,43 @@
       assert.deepEqual(result[1].ab, content[0].ab.slice(120));
     });
 
+    test('breaks down added chunks', () => {
+      const size = 120 * 2 + 5;
+      const content = _.times(size, () => `${Math.random()}`);
+      element.context = 5;
+      const splitContent = element._splitLargeChunks([{a: [], b: content}])
+          .map(r => r.b);
+      assert.equal(splitContent.length, 3);
+      assert.deepEqual(splitContent[0], content.slice(0, 5));
+      assert.deepEqual(splitContent[1], content.slice(5, 125));
+      assert.deepEqual(splitContent[2], content.slice(125));
+    });
+
+    test('breaks down removed chunks', () => {
+      const size = 120 * 2 + 5;
+      const content = _.times(size, () => `${Math.random()}`);
+      element.context = 5;
+      const splitContent = element._splitLargeChunks([{a: content, b: []}])
+          .map(r => r.a);
+      assert.equal(splitContent.length, 3);
+      assert.deepEqual(splitContent[0], content.slice(0, 5));
+      assert.deepEqual(splitContent[1], content.slice(5, 125));
+      assert.deepEqual(splitContent[2], content.slice(125));
+    });
+
+    test('does not break down moved chunks', () => {
+      const size = 120 * 2 + 5;
+      const content = _.times(size, () => `${Math.random()}`);
+      element.context = 5;
+      const splitContent = element._splitLargeChunks([{
+        a: content,
+        b: [],
+        move_details: {changed: false},
+      }]).map(r => r.a);
+      assert.equal(splitContent.length, 1);
+      assert.deepEqual(splitContent[0], content);
+    });
+
     test('does not break-down common chunks w/ context', () => {
       const content = [{
         ab: _.times(75, () => `${Math.random()}`),
@@ -703,16 +750,15 @@
         ],
       };
       const content = _.times(200, _.constant(contentRow));
-      sinon.stub(element, 'async');
       element._isScrolling = true;
       element.process(content);
       // Just the files group - no more processing during scrolling.
-      assert.equal(element.groups.length, 1);
+      assert.equal(element.groups.length, 2);
 
       element._isScrolling = false;
       element.process(content);
       // More groups have been processed. How many does not matter here.
-      assert.isAtLeast(element.groups.length, 2);
+      assert.isAtLeast(element.groups.length, 3);
     });
 
     test('image diffs', () => {
@@ -723,9 +769,8 @@
         ],
       };
       const content = _.times(200, _.constant(contentRow));
-      sinon.stub(element, 'async');
       element.process(content, true);
-      assert.equal(element.groups.length, 1);
+      assert.equal(element.groups.length, 2);
 
       // Image diffs don't process content, just the 'FILE' line.
       assert.equal(element.groups[0].lines.length, 1);
@@ -797,20 +842,26 @@
         assert.deepEqual(
             skippedGroup.lineRange,
             {
-              left: {start: lineNums.left + 1, end: lineNums.left + skip},
-              right: {start: lineNums.right + 1, end: lineNums.right + skip},
+              left: {
+                start_line: lineNums.left + 1,
+                end_line: lineNums.left + skip,
+              },
+              right: {
+                start_line: lineNums.right + 1,
+                end_line: lineNums.right + skip,
+              },
             });
 
         assert.deepEqual(
             abGroup.lineRange,
             {
               left: {
-                start: lineNums.left + skip + 1,
-                end: lineNums.left + skip + rows.length,
+                start_line: lineNums.left + skip + 1,
+                end_line: lineNums.left + skip + rows.length,
               },
               right: {
-                start: lineNums.right + skip + 1,
-                end: lineNums.right + skip + rows.length,
+                start_line: lineNums.right + skip + 1,
+                end_line: lineNums.right + skip + rows.length,
               },
             });
       });
@@ -1019,16 +1070,6 @@
             }
           });
 
-      test('_breakdownChunk keeps due_to_move for broken down additions',
-          () => {
-            sinon.spy(element, '_breakdown');
-            const chunk = {b: ['blah', 'blah', 'blah'], due_to_move: true};
-            const result = element._breakdownChunk(chunk);
-            for (const subResult of result) {
-              assert.isTrue(subResult.due_to_move);
-            }
-          });
-
       test('_breakdown common case', () => {
         const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
             .split(' ');
@@ -1070,7 +1111,7 @@
   test('detaching cancels', () => {
     element = basicFixture.instantiate();
     sinon.stub(element, 'cancel');
-    element.detached();
+    element.disconnectedCallback();
     assert(element.cancel.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
index b75ba8f..3df2e18 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
@@ -17,8 +17,6 @@
 import '../../../styles/shared-styles';
 import {addListener} from '@polymer/polymer/lib/utils/gestures';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-selection_html';
 import {
@@ -27,9 +25,10 @@
 } from '../gr-diff-highlight/gr-range-normalizer';
 import {descendedFromClass, querySelectorAll} from '../../../utils/dom-util';
 import {customElement, property, observe} from '@polymer/decorators';
-import {DiffInfo} from '../../../types/common';
+import {DiffInfo} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
+import {getSide, isThreadEl} from '../gr-diff/gr-diff-utils';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -52,9 +51,7 @@
 }
 
 @customElement('gr-diff-selection')
-export class GrDiffSelection extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffSelection extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -68,16 +65,15 @@
   @property({type: Object})
   _linesCache: LinesCache = {left: null, right: null};
 
-  /** @override */
-  created() {
-    super.created();
+  constructor() {
+    super();
     this.addEventListener('copy', e => this._handleCopy(e));
     addListener(this, 'down', e => this._handleDown(e));
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.classList.add(SelectionClass.RIGHT);
   }
 
@@ -96,10 +92,10 @@
   }
 
   _handleDownOnRangeComment(node: Element) {
-    if (node?.nodeName?.toLowerCase() === 'gr-comment-thread') {
+    if (isThreadEl(node)) {
       this._setClasses([
         SelectionClass.COMMENT,
-        node.getAttribute('comment-side') === Side.LEFT
+        getSide(node) === Side.LEFT
           ? SelectionClass.LEFT
           : SelectionClass.RIGHT,
       ]);
@@ -198,7 +194,7 @@
     if (!diffHosts.length) return window.getSelection();
 
     const curDiffHost = diffHosts.find(diffHost => {
-      if (!diffHost || !diffHost.shadowRoot) return false;
+      if (!diffHost?.shadowRoot?.getSelection) return false;
       const selection = diffHost.shadowRoot.getSelection();
       // Pick the one with valid selection:
       // https://developer.mozilla.org/en-US/docs/Web/API/Selection/type
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index fc46123..4a51349 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -21,7 +21,6 @@
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
 import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
 import '../../shared/revision-info/revision-info';
 import '../gr-comment-api/gr-comment-api';
@@ -32,21 +31,17 @@
 import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../gr-patch-range-select/gr-patch-range-select';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-view_html';
 import {
   KeyboardShortcutMixin,
   Shortcut,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
-  patchNumEquals,
   PatchSet,
 } from '../../../utils/patch-set-util';
 import {
@@ -58,25 +53,19 @@
 } from '../../../utils/path-list-util';
 import {changeBaseURL, changeIsOpen} from '../../../utils/change-util';
 import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrDiffHost} from '../gr-diff-host/gr-diff-host';
 import {
   DropdownItem,
   GrDropdownList,
 } from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {
-  ChangeComments,
-  GrCommentApi,
-  TwoSidesComments,
-} from '../gr-comment-api/gr-comment-api';
+import {ChangeComments, GrCommentApi} from '../gr-comment-api/gr-comment-api';
 import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
 import {
+  BasePatchSetNum,
   ChangeInfo,
   CommitId,
   ConfigInfo,
-  DiffInfo,
-  DiffPreferencesInfo,
   EditInfo,
   EditPatchSetNum,
   ElementPropertyDeepChange,
@@ -88,20 +77,28 @@
   PreferencesInfo,
   RepoName,
   RevisionInfo,
+  RevisionPatchSetNum,
 } from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {ChangeViewState, CommitRange, FileRange} from '../../../types/types';
 import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrDiffCursor} from '../gr-diff-cursor/gr-diff-cursor';
 import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {LineOfInterest} from '../gr-diff/gr-diff';
 import {RevisionInfo as RevisionInfoObj} from '../../shared/revision-info/revision-info';
-import {CommentMap} from '../../../utils/comment-util';
+import {
+  CommentMap,
+  isInBaseOfPatchRange,
+  getPatchRangeForCommentUrl,
+} from '../../../utils/comment-util';
 import {AppElementParams} from '../../gr-app-types';
 import {CustomKeyboardEvent, OpenFixPreviewEvent} from '../../../types/events';
-
+import {fireAlert, 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';
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
 const MSG_LOADED_BLAME = 'Blame loaded';
@@ -118,7 +115,6 @@
 
 export interface GrDiffView {
   $: {
-    restAPI: RestApiService & Element;
     commentAPI: GrCommentApi;
     cursor: GrDiffCursor;
     diffHost: GrDiffHost;
@@ -131,9 +127,7 @@
 }
 
 @customElement('gr-diff-view')
-export class GrDiffView extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrDiffView extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -188,9 +182,7 @@
 
   @property({
     type: Array,
-    computed:
-      '_formatFilesForDropdown(_files, ' +
-      '_patchRange.patchNum, _changeComments)',
+    computed: '_formatFilesForDropdown(_files, _patchRange, _changeComments)',
   })
   _formattedFiles?: DropdownItem[];
 
@@ -239,9 +231,6 @@
   @property({type: Object})
   _commentMap?: CommentMap;
 
-  @property({type: Object})
-  _commentsForDiff?: TwoSidesComments;
-
   @property({
     type: Object,
     computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
@@ -302,7 +291,7 @@
       [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
       [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
       [Shortcut.TOGGLE_FILE_REVIEWED]: '_throttledToggleFileReviewed',
-      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
+      [Shortcut.TOGGLE_ALL_DIFF_CONTEXT]: '_handleToggleAllDiffContext',
       [Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
       [Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
       [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
@@ -324,6 +313,8 @@
 
   flagsService = appContext.flagsService;
 
+  private readonly restApiService = appContext.restApiService;
+
   _throttledToggleFileReviewed?: EventListener;
 
   _onRenderHandler?: EventListener;
@@ -334,11 +325,6 @@
     this._throttledToggleFileReviewed = this._throttleWrap(e =>
       this._handleToggleFileReviewed(e as CustomKeyboardEvent)
     );
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
@@ -352,26 +338,27 @@
   }
 
   /** @override */
-  detached() {
+  disconnectedCallback() {
     if (this._onRenderHandler) {
       this.$.diffHost.removeEventListener('render', this._onRenderHandler);
     }
+    super.disconnectedCallback();
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   @observe('_change.project')
   _getProjectConfig(project?: RepoName) {
     if (!project) return;
-    return this.$.restAPI.getProjectConfig(project).then(config => {
+    return this.restApiService.getProjectConfig(project).then(config => {
       this._projectConfig = config;
     });
   }
 
   _getChangeDetail(changeNum: NumericChangeId) {
-    return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+    return this.restApiService.getDiffChangeDetail(changeNum).then(change => {
       if (!change) throw new Error('Missing "change" in API response.');
       this._change = change;
       return change;
@@ -379,8 +366,8 @@
   }
 
   _getChangeEdit() {
-    if (!this._changeNum) throw new Error('Missing this._changeNum');
-    return this.$.restAPI.getChangeEdit(this._changeNum);
+    assertIsDefined(this._changeNum, '_changeNum');
+    return this.restApiService.getChangeEdit(this._changeNum);
   }
 
   _getSortedFileList(files?: Files) {
@@ -418,7 +405,7 @@
     }
 
     const patchRange = patchRangeRecord.base;
-    return this.$.restAPI
+    return this.restApiService
       .getChangeFiles(changeNum, patchRange)
       .then(changeFiles => {
         if (!changeFiles) return;
@@ -433,13 +420,13 @@
   }
 
   _getDiffPreferences() {
-    return this.$.restAPI.getDiffPreferences().then(prefs => {
+    return this.restApiService.getDiffPreferences().then(prefs => {
       this._prefs = prefs;
     });
   }
 
   _getPreferences() {
-    return this.$.restAPI.getPreferences();
+    return this.restApiService.getPreferences();
   }
 
   _getWindowWidth() {
@@ -457,13 +444,7 @@
     this.$.reviewed.checked = reviewed;
     if (!this._patchRange?.patchNum) return;
     this._saveReviewedState(reviewed).catch(err => {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_REVIEW_STATUS},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_REVIEW_STATUS);
       throw err;
     });
   }
@@ -472,7 +453,7 @@
     if (!this._changeNum) return Promise.resolve(undefined);
     if (!this._patchRange?.patchNum) return Promise.resolve(undefined);
     if (!this._path) return Promise.resolve(undefined);
-    return this.$.restAPI.saveFileReviewed(
+    return this.restApiService.saveFileReviewed(
       this._changeNum,
       this._patchRange?.patchNum,
       this._path,
@@ -605,6 +586,7 @@
     if (this.modifierPressed(e)) return;
 
     e.preventDefault();
+    this.classList.remove('hideComments');
     this.$.cursor.createCommentInPlace();
   }
 
@@ -654,18 +636,24 @@
       this.$.cursor.moveToPreviousCommentThread();
     } else {
       if (this.modifierPressed(e)) return;
-      this.$.cursor.moveToPreviousChunk();
+      this.$.cursor.moveToPreviousChunk(!e.detail.keyboardEvent?.repeat);
     }
   }
 
+  // Similar to gr-change-view._handleOpenReplyDialog
   _handleOpenReplyDialog(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e)) return;
     if (this.modifierPressed(e)) return;
-    if (!this._loggedIn) return;
+    this._getLoggedIn().then(isLoggedIn => {
+      if (!isLoggedIn) {
+        fireEvent(this, 'show-auth-required');
+        return;
+      }
 
-    this.set('changeViewState.showReplyDialog', true);
-    e.preventDefault();
-    this._navToChangeView();
+      this.set('changeViewState.showReplyDialog', true);
+      e.preventDefault();
+      this._navToChangeView();
+    });
   }
 
   _handleToggleLeftPane(e: CustomKeyboardEvent) {
@@ -725,7 +713,12 @@
     );
   }
 
-  _navToFile(path: string, fileList: string[], direction: -1 | 1) {
+  _navToFile(
+    path: string,
+    fileList: string[],
+    direction: -1 | 1,
+    navigateToFirstComment?: boolean
+  ) {
     const newPath = this._getNavLinkPath(path, fileList, direction);
     if (!newPath) return;
     if (!this._change) return;
@@ -741,11 +734,18 @@
     }
 
     if (!newPath.path) return;
+    let lineNum;
+    if (navigateToFirstComment)
+      lineNum = this._changeComments?.getCommentsForPath(
+        newPath.path,
+        this._patchRange
+      )?.[0].line;
     GerritNav.navigateToDiff(
       this._change,
       newPath.path,
       this._patchRange.patchNum,
-      this._patchRange.basePatchNum
+      this._patchRange.basePatchNum,
+      lineNum
     );
   }
 
@@ -754,8 +754,6 @@
    * @param fileList The list of files in this change and
    * patch range.
    * @param direction Either 1 (next file) or -1 (prev file).
-   * @param opt_noUp Whether to return to the change view
-   * when advancing the file goes outside the bounds of fileList.
    * @return The next URL when proceeding in the specified
    * direction.
    */
@@ -763,15 +761,14 @@
     change?: ChangeInfo,
     path?: string,
     fileList?: string[],
-    direction?: -1 | 1,
-    opt_noUp?: boolean
+    direction?: -1 | 1
   ) {
     if (!change) return null;
     if (!path) return null;
     if (!fileList) return null;
     if (!direction) return null;
 
-    const newPath = this._getNavLinkPath(path, fileList, direction, opt_noUp);
+    const newPath = this._getNavLinkPath(path, fileList, direction);
     if (!newPath) {
       return null;
     }
@@ -816,15 +813,8 @@
    * @param fileList The list of files in this change and
    * patch range.
    * @param direction Either 1 (next file) or -1 (prev file).
-   * @param opt_noUp Whether to return to the change view
-   * when advancing the file goes outside the bounds of fileList.
    */
-  _getNavLinkPath(
-    path: string,
-    fileList: string[],
-    direction: -1 | 1,
-    opt_noUp?: boolean
-  ) {
+  _getNavLinkPath(path: string, fileList: string[], direction: -1 | 1) {
     if (!path || !fileList || fileList.length === 0) {
       return null;
     }
@@ -839,9 +829,6 @@
     // Redirect to the change view if opt_noUp isn’t truthy and idx falls
     // outside the bounds of [0, fileList.length).
     if (idx < 0 || idx > fileList.length - 1) {
-      if (opt_noUp) {
-        return null;
-      }
       return {up: true};
     }
 
@@ -853,10 +840,12 @@
     patchNum?: PatchSetNum
   ): Promise<Set<string>> {
     if (!changeNum || !patchNum) return Promise.resolve(new Set<string>());
-    return this.$.restAPI.getReviewedFiles(changeNum, patchNum).then(files => {
-      this._reviewedFiles = new Set(files);
-      return this._reviewedFiles;
-    });
+    return this.restApiService
+      .getReviewedFiles(changeNum, patchNum)
+      .then(files => {
+        this._reviewedFiles = new Set(files);
+        return this._reviewedFiles;
+      });
   }
 
   _getReviewedStatus(
@@ -880,51 +869,36 @@
 
   _displayDiffBaseAgainstLeftToast() {
     if (!this._patchRange) return;
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {
-          // \u2190 = ←
-          message:
-            `Patchset ${this._patchRange.basePatchNum} vs ` +
-            `${this._patchRange.patchNum} selected. Press v + \u2190 to view ` +
-            `Base vs ${this._patchRange.basePatchNum}`,
-        },
-        composed: true,
-        bubbles: true,
-      })
+    fireAlert(
+      this,
+      `Patchset ${this._patchRange.basePatchNum} vs ` +
+        `${this._patchRange.patchNum} selected. Press v + \u2190 to view ` +
+        `Base vs ${this._patchRange.basePatchNum}`
     );
   }
 
   _displayDiffAgainstLatestToast(latestPatchNum?: PatchSetNum) {
     if (!this._patchRange) return;
-    const leftPatchset = patchNumEquals(
-      this._patchRange.basePatchNum,
-      ParentPatchSetNum
-    )
-      ? 'Base'
-      : `Patchset ${this._patchRange.basePatchNum}`;
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {
-          // \u2191 = ↑
-          message: `${leftPatchset} vs
+    const leftPatchset =
+      this._patchRange.basePatchNum === ParentPatchSetNum
+        ? 'Base'
+        : `Patchset ${this._patchRange.basePatchNum}`;
+    fireAlert(
+      this,
+      `${leftPatchset} vs
             ${this._patchRange.patchNum} selected\n. Press v + \u2191 to view
-            ${leftPatchset} vs Patchset ${latestPatchNum}`,
-        },
-        composed: true,
-        bubbles: true,
-      })
+            ${leftPatchset} vs Patchset ${latestPatchNum}`
     );
   }
 
   _displayToasts() {
     if (!this._patchRange) return;
-    if (!patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+    if (this._patchRange.basePatchNum !== ParentPatchSetNum) {
       this._displayDiffBaseAgainstLeftToast();
       return;
     }
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (!patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+    if (this._patchRange.patchNum !== latestPatchNum) {
       this._displayDiffAgainstLatestToast(latestPatchNum);
       return;
     }
@@ -935,27 +909,43 @@
     let baseCommit: CommitId | undefined;
     if (!this._change) return;
     if (!this._patchRange || !this._patchRange.patchNum) return;
-    for (const commitSha in this._change.revisions) {
-      if (!hasOwnProperty(this._change.revisions, commitSha)) continue;
-      const revision = this._change.revisions[commitSha];
+    const revisions = this._change.revisions ?? {};
+    for (const [commitSha, revision] of Object.entries(revisions)) {
       const patchNum = revision._number;
-      if (patchNumEquals(patchNum, this._patchRange.patchNum)) {
+      if (patchNum === this._patchRange.patchNum) {
         commit = commitSha as CommitId;
         const commitObj = revision.commit;
         const parents = commitObj?.parents || [];
         if (
-          patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum) &&
+          this._patchRange.basePatchNum === ParentPatchSetNum &&
           parents.length
         ) {
           baseCommit = parents[parents.length - 1].commit;
         }
-      } else if (patchNumEquals(patchNum, this._patchRange.basePatchNum)) {
+      } else if (patchNum === this._patchRange.basePatchNum) {
         baseCommit = commitSha as CommitId;
       }
     }
     this._commitRange = commit && baseCommit ? {commit, baseCommit} : undefined;
   }
 
+  _updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
+    if (!this._change) return;
+    if (!this._patchRange) return;
+    if (!this._changeNum) return;
+    if (!this._path) return;
+    const url = GerritNav.getUrlForDiffById(
+      this._changeNum,
+      this._change.project,
+      this._path,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum,
+      lineNum,
+      leftSide
+    );
+    history.replaceState(null, '', url);
+  }
+
   _initPatchRange() {
     let leftSide = false;
     if (!this._change) return;
@@ -965,37 +955,17 @@
         this.params.commentId
       );
       if (!comment) {
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {
-              message: 'comment not found',
-            },
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireAlert(this, 'comment not found');
         GerritNav.navigateToChange(this._change);
         return;
       }
       this._path = comment.path;
+
       const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-      if (!comment.patch_set) throw new Error('Missing comment.patch_set');
       if (!latestPatchNum) throw new Error('Missing _allPatchSets');
-      if (patchNumEquals(latestPatchNum, comment.patch_set)) {
-        this._patchRange = {
-          patchNum: latestPatchNum,
-          basePatchNum: ParentPatchSetNum,
-        };
-        leftSide = comment.__commentSide === 'left';
-      } else {
-        this._patchRange = {
-          patchNum: latestPatchNum,
-          basePatchNum: comment.patch_set,
-        };
-        // comment is now on the left side since we are showing
-        // comment.patch_set vs latest
-        leftSide = true;
-      }
+      this._patchRange = getPatchRangeForCommentUrl(comment, latestPatchNum);
+      leftSide = isInBaseOfPatchRange(comment, this._patchRange);
+
       this._focusLineNum = comment.line;
     } else {
       if (this.params.path) {
@@ -1012,15 +982,15 @@
         leftSide = !!this.params.leftSide;
       }
     }
-    if (!this._patchRange) throw new Error('Failed to initialize patchRange.');
+    assertIsDefined(this._patchRange, '_patchRange');
     this._initLineOfInterestAndCursor(leftSide);
-    this._commentMap = this._getPaths(this._patchRange);
 
-    this._commentsForDiff = this._getCommentsForPath(
-      this._path,
-      this._patchRange,
-      this._projectConfig
-    );
+    if (this.params?.commentId) {
+      // url is of type /comment/{commentId} which isn't meaningful
+      this._updateUrlToDiffUrl(this._focusLineNum, leftSide);
+    }
+
+    this._commentMap = this._getPaths(this._patchRange);
   }
 
   _isFileUnchanged(diff: DiffInfo) {
@@ -1045,7 +1015,7 @@
     this._focusLineNum = undefined;
 
     if (value.changeNum && value.project) {
-      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+      this.restApiService.setInProjectLookup(value.changeNum, value.project);
     }
 
     this._changeNum = value.changeNum;
@@ -1071,7 +1041,7 @@
     );
 
     promises.push(this._getChangeDetail(this._changeNum));
-    promises.push(this._loadComments());
+    promises.push(this._loadComments(value.patchNum));
 
     promises.push(this._getChangeEdit());
 
@@ -1083,7 +1053,18 @@
         this._loading = false;
         this._initPatchRange();
         this._initCommitRange();
-        this.$.diffHost.comments = this._commentsForDiff;
+
+        assertIsDefined(this._path, '_path');
+        if (!this._changeComments)
+          throw new Error('change comments must be defined');
+        assertIsDefined(this._patchRange, '_patchRange');
+
+        // TODO(dhruvsri): check if basePath should be set here
+        this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
+          {path: this._path},
+          this._patchRange
+        );
+
         const edit = r[4] as EditInfo | undefined;
         if (edit) {
           this.set(`_change.revisions.${edit.commit.commit}`, {
@@ -1103,9 +1084,9 @@
         if (!this._diff) throw new Error('Missing this._diff');
         const fileUnchanged = this._isFileUnchanged(this._diff);
         if (fileUnchanged && value.commentLink) {
-          if (!this._change) throw new Error('Missing this._change');
-          if (!this._path) throw new Error('Missing this._path');
-          if (!this._patchRange) throw new Error('Missing this._patchRange');
+          assertIsDefined(this._change, '_change');
+          assertIsDefined(this._path, '_path');
+          assertIsDefined(this._patchRange, '_patchRange');
 
           if (this._patchRange.basePatchNum === ParentPatchSetNum) {
             // file is unchanged between Base vs X
@@ -1113,17 +1094,12 @@
             return;
           }
 
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {
-                message: `File is unchanged between Patchset
+          fireAlert(
+            this,
+            `File is unchanged between Patchset
                   ${this._patchRange.basePatchNum} and
                   ${this._patchRange.patchNum}. Showing diff of Base vs
-                  ${this._patchRange.basePatchNum}`,
-              },
-              composed: true,
-              bubbles: true,
-            })
+                  ${this._patchRange.basePatchNum}`
           );
           GerritNav.navigateToDiff(
             this._change,
@@ -1146,7 +1122,7 @@
   _changeViewStateChanged(changeViewState: Partial<ChangeViewState>) {
     if (changeViewState.diffMode === null) {
       // If screen size is small, always default to unified view.
-      this.$.restAPI.getPreferences().then(prefs => {
+      this.restApiService.getPreferences().then(prefs => {
         if (prefs) {
           this.set('changeViewState.diffMode', prefs.default_diff_view);
         }
@@ -1221,13 +1197,7 @@
 
   _pathChanged(path: string) {
     if (path) {
-      this.dispatchEvent(
-        new CustomEvent('title-change', {
-          detail: {title: computeTruncatedPath(path)},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireTitleChange(this, computeTruncatedPath(path));
     }
 
     if (!this._fileList || this._fileList.length === 0) return;
@@ -1276,7 +1246,7 @@
     if (!patchRange) return {patchNum, basePatchNum};
     if (
       patchRange.basePatchNum !== ParentPatchSetNum ||
-      !patchNumEquals(patchRange.patchNum, latestPatchNum as PatchSetNum)
+      patchRange.patchNum !== latestPatchNum
     ) {
       patchNum = patchRange.patchNum;
       basePatchNum = patchRange.basePatchNum;
@@ -1321,11 +1291,11 @@
 
   _formatFilesForDropdown(
     files?: Files,
-    patchNum?: PatchSetNum,
+    patchRange?: PatchRange,
     changeComments?: ChangeComments
   ): DropdownItem[] {
     if (!files) return [];
-    if (!patchNum) return [];
+    if (!patchRange) return [];
     if (!changeComments) return [];
 
     const dropdownContent: DropdownItem[] = [];
@@ -1334,51 +1304,18 @@
         text: computeDisplayPath(path),
         mobileText: computeTruncatedPath(path),
         value: path,
-        bottomText: this._computeCommentString(
-          changeComments,
-          patchNum,
+        bottomText: changeComments.computeCommentsString(
+          patchRange,
           path,
-          files.changeFilesByPath[path]
+          files.changeFilesByPath[path],
+          /* includeUnmodified= */ true
         ),
+        file: {...files.changeFilesByPath[path], __path: path},
       });
     }
     return dropdownContent;
   }
 
-  _computeCommentString(
-    changeComments?: ChangeComments,
-    patchNum?: PatchSetNum,
-    path?: string,
-    changeFileInfo?: FileInfo
-  ) {
-    if (!changeComments) return '';
-    if (!path) return '';
-    if (!changeFileInfo) return '';
-
-    const unresolvedCount = changeComments.computeUnresolvedNum({
-      patchNum,
-      path,
-    });
-    const commentThreadCount = changeComments.computeCommentThreadCount({
-      patchNum,
-      path,
-    });
-    const commentThreadString = GrCountStringFormatter.computePluralString(
-      commentThreadCount,
-      'comment'
-    );
-    const unresolvedString = GrCountStringFormatter.computeString(
-      unresolvedCount,
-      'unresolved'
-    );
-
-    const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes' : '';
-
-    return [unmodifiedString, commentThreadString, unresolvedString]
-      .filter(v => v && v.length > 0)
-      .join(', ');
-  }
-
   _computePrefsButtonHidden(
     prefs?: DiffPreferencesInfo,
     prefsDisabled?: boolean
@@ -1411,8 +1348,8 @@
 
     const {basePatchNum, patchNum} = e.detail;
     if (
-      patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
-      patchNumEquals(patchNum, this._patchRange.patchNum)
+      basePatchNum === this._patchRange.basePatchNum &&
+      patchNum === this._patchRange.patchNum
     ) {
       return;
     }
@@ -1455,26 +1392,12 @@
     _: Event,
     detail: {side: Side | CommentSide; number: number}
   ) {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._changeNum) return;
-    if (!this._patchRange) return;
-
-    const number = detail.number;
     // for on-comment-anchor-tap side can be PARENT/REVISIONS
     // for on-line-selected side can be left/right
-    const leftSide =
-      detail.side === Side.LEFT || detail.side === CommentSide.PARENT;
-    const url = GerritNav.getUrlForDiffById(
-      this._changeNum,
-      this._change.project,
-      this._path,
-      this._patchRange.patchNum,
-      this._patchRange.basePatchNum,
-      number,
-      leftSide
+    this._updateUrlToDiffUrl(
+      detail.number,
+      detail.side === Side.LEFT || detail.side === CommentSide.PARENT
     );
-    history.replaceState(null, '', url);
   }
 
   _computeDownloadDropdownLinks(
@@ -1543,17 +1466,17 @@
   ) {
     let patchNum = patchRange.patchNum;
 
-    const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
+    const comparedAgainstParent = patchRange.basePatchNum === 'PARENT';
 
-    if (isBase && !comparedAgainsParent) {
-      patchNum = patchRange.basePatchNum;
+    if (isBase && !comparedAgainstParent) {
+      patchNum = patchRange.basePatchNum as RevisionPatchSetNum;
     }
 
     let url =
       changeBaseURL(project, changeNum, patchNum) +
       `/files/${encodeURIComponent(path)}/download`;
 
-    if (isBase && comparedAgainsParent) {
+    if (isBase && comparedAgainstParent) {
       url += '?parent=1';
     }
 
@@ -1571,11 +1494,13 @@
     return url;
   }
 
-  _loadComments() {
-    if (!this._changeNum) throw new Error('Missing this._changeNum');
-    return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
-      this._changeComments = comments;
-    });
+  _loadComments(patchSet?: PatchSetNum) {
+    assertIsDefined(this._changeNum, '_changeNum');
+    return this.$.commentAPI
+      .loadAll(this._changeNum, patchSet)
+      .then(comments => {
+        this._changeComments = comments;
+      });
   }
 
   @observe('_files.changeFilesByPath', '_path', '_patchRange', '_projectConfig')
@@ -1593,13 +1518,10 @@
 
     const file = files[path];
     if (file && file.old_path) {
-      this._commentsForDiff = this._changeComments.getCommentsBySideForFile(
+      this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
         {path, basePath: file.old_path},
-        patchRange,
-        projectConfig
+        patchRange
       );
-
-      this.$.diffHost.comments = this._commentsForDiff;
     }
   }
 
@@ -1608,26 +1530,10 @@
     return this._changeComments.getPaths(patchRange);
   }
 
-  _getCommentsForPath(
-    path?: string,
-    patchRange?: PatchRange,
-    projectConfig?: ConfigInfo
-  ) {
-    if (!path) return undefined;
-    if (!patchRange) return undefined;
-    if (!this._changeComments) return undefined;
-
-    return this._changeComments.getCommentsBySideForPath(
-      path,
-      patchRange,
-      projectConfig
-    );
-  }
-
   _getDiffDrafts() {
-    if (!this._changeNum) throw new Error('Missing this._changeNum');
+    assertIsDefined(this._changeNum, '_changeNum');
 
-    return this.$.restAPI.getDiffDrafts(this._changeNum);
+    return this.restApiService.getDiffDrafts(this._changeNum);
   }
 
   _computeCommentSkips(
@@ -1672,7 +1578,7 @@
     patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
   ) {
     const patchRange = patchRangeRecord.base || {};
-    return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+    return patchRange.patchNum === EditPatchSetNum;
   }
 
   _computeBlameToggleLabel(loaded?: boolean, loading?: boolean) {
@@ -1681,24 +1587,12 @@
 
   _loadBlame() {
     this._isBlameLoading = true;
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {message: MSG_LOADING_BLAME},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireAlert(this, MSG_LOADING_BLAME);
     this.$.diffHost
       .loadBlame()
       .then(() => {
         this._isBlameLoading = false;
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: MSG_LOADED_BLAME},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireAlert(this, MSG_LOADED_BLAME);
       })
       .catch(() => {
         this._isBlameLoading = false;
@@ -1728,7 +1622,7 @@
     if (this.shouldSuppressKeyboardShortcut(e)) return;
     if (this.modifierPressed(e)) return;
 
-    this.toggleClass('hideComments');
+    toggleClass(this, 'hideComments');
   }
 
   _handleOpenFileList(e: CustomKeyboardEvent) {
@@ -1743,16 +1637,8 @@
     if (!this._path) return;
     if (!this._patchRange) return;
 
-    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Base is already selected.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+      fireAlert(this, 'Base is already selected.');
       return;
     }
     GerritNav.navigateToDiff(
@@ -1768,23 +1654,15 @@
     if (!this._path) return;
     if (!this._patchRange) return;
 
-    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Left is already base.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+      fireAlert(this, 'Left is already base.');
       return;
     }
     GerritNav.navigateToDiff(
       this._change,
       this._path,
       this._patchRange.basePatchNum,
-      'PARENT' as PatchSetNum,
+      'PARENT' as BasePatchSetNum,
       this.params?.view === GerritView.DIFF && this.params?.commentLink
         ? this._focusLineNum
         : undefined
@@ -1798,16 +1676,8 @@
     if (!this._patchRange) return;
 
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Latest is already selected.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+    if (this._patchRange.patchNum === latestPatchNum) {
+      fireAlert(this, 'Latest is already selected.');
       return;
     }
 
@@ -1826,23 +1696,15 @@
     if (!this._patchRange) return;
 
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Right is already latest.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+    if (this._patchRange.patchNum === latestPatchNum) {
+      fireAlert(this, 'Right is already latest.');
       return;
     }
     GerritNav.navigateToDiff(
       this._change,
       this._path,
       latestPatchNum,
-      this._patchRange.patchNum
+      this._patchRange.patchNum as BasePatchSetNum
     );
   }
 
@@ -1854,18 +1716,10 @@
 
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
     if (
-      patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
-      patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+      this._patchRange.patchNum === latestPatchNum &&
+      this._patchRange.basePatchNum === ParentPatchSetNum
     ) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Already diffing base against latest.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Already diffing base against latest.');
       return;
     }
     GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
@@ -1892,23 +1746,20 @@
     return '';
   }
 
-  _handleExpandAllDiffContext(e: CustomKeyboardEvent) {
+  _handleToggleAllDiffContext(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e)) return;
 
-    this.$.diffHost.expandAllContext();
+    this.$.diffHost.toggleAllContext();
   }
 
   _computeDiffPrefsDisabled(disableDiffPrefs?: boolean, loggedIn?: boolean) {
     return disableDiffPrefs || !loggedIn;
   }
 
-  _handleNextUnreviewedFile(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
+  _navigateToNextUnreviewedFile() {
     if (!this._path) return;
     if (!this._fileList) return;
     if (!this._reviewedFiles) return;
-
-    this._setReviewed(true);
     // Ensure that the currently viewed file always appears in unreviewedFiles
     // so we resolve the right "next" file.
     const unreviewedFiles = this._fileList.filter(
@@ -1917,6 +1768,38 @@
     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;
+    this._setReviewed(true);
+    this._navigateToNextUnreviewedFile();
+  }
+
+  _navigateToNextFileWithCommentThread() {
+    if (!this._path) return;
+    if (!this._fileList) return;
+    if (!this._patchRange) return;
+    if (!this._change) return;
+    const hasComment = (path: string) =>
+      this._changeComments?.getCommentsForPath(path, this._patchRange!)
+        ?.length ?? 0 > 0;
+    const filesWithComments = this._fileList.filter(
+      file => file === this._path || hasComment(file)
+    );
+    this._navToFile(this._path, filesWithComments, 1, true);
+  }
+
   _handleReloadingDiffPreference() {
     this._getDiffPreferences();
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
index be2dce5..30c6f49 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -255,7 +255,7 @@
           class="navLink"
           title="[[createTitle(Shortcut.PREV_FILE,
                     ShortcutSection.NAVIGATION)]]"
-          href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]"
+          href$="[[_computeNavLinkURL(_change, _path, _fileList, -1)]]"
         >
           Prev</a
         >
@@ -273,7 +273,7 @@
           class="navLink"
           title="[[createTitle(Shortcut.NEXT_FILE,
                 ShortcutSection.NAVIGATION)]]"
-          href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]"
+          href$="[[_computeNavLinkURL(_change, _path, _fileList, 1)]]"
         >
           Next</a
         >
@@ -302,9 +302,7 @@
             items="[[_computeDownloadDropdownLinks(_change.project, _changeNum, _patchRange, _path, _diff)]]"
             horizontal-align="left"
           >
-            <span class="downloadTitle">
-              Download
-            </span>
+            <span class="downloadTitle"> Download </span>
           </gr-dropdown>
         </span>
       </div>
@@ -376,14 +374,14 @@
     <div class="fileNav mobile">
       <a
         class="mobileNavLink"
-        href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]"
+        href$="[[_computeNavLinkURL(_change, _path, _fileList, -1)]]"
       >
         &lt;</a
       >
       <div class="fullFileName mobile">[[_computeDisplayPath(_path)]]</div>
       <a
         class="mobileNavLink"
-        href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]"
+        href$="[[_computeNavLinkURL(_change, _path, _fileList, 1)]]"
       >
         &gt;</a
       >
@@ -425,11 +423,11 @@
     on-reload-diff-preference="_handleReloadingDiffPreference"
   >
   </gr-diff-preferences-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
   <gr-diff-cursor
     id="cursor"
-    on-navigate-to-next-unreviewed-file="_handleNextUnreviewedFile"
+    on-navigate-to-next-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 9a08723..5c3dfdc 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,16 +19,16 @@
 import './gr-diff-view.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {ChangeStatus} from '../../../constants/constants.js';
-import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
-import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
+import {TestKeyboardShortcutBinder, stubRestApi} from '../../../test/test-utils.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {_testOnly_findCommentById} from '../gr-comment-api/gr-comment-api.js';
-import {appContext} from '../../../services/app-context.js';
-import {GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {ChangeComments, _testOnly_findCommentById, _testOnly_getCommentsForPath} from '../gr-comment-api/gr-comment-api.js';
+import {GerritView} from '../../../services/router/router-model.js';
 import {
   createChange,
   createRevisions,
+  createComment,
 } from '../../../test/test-data-generators.js';
+import {EditPatchSetNum} from '../../../types/common.js';
 
 const basicFixture = fixtureFromElement('gr-diff-view');
 
@@ -62,7 +62,7 @@
       kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
       kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
       kb.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r');
-      kb.bindShortcut(Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+      kb.bindShortcut(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, 'shift+x');
       kb.bindShortcut(Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e');
       kb.bindShortcut(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
       kb.bindShortcut(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
@@ -88,41 +88,24 @@
       };
     }
 
+    let getDiffChangeDetailStub;
+    let getReviewedFilesStub;
     setup(async () => {
       clock = sinon.useFakeTimers();
-      sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
-      stub('gr-rest-api-interface', {
-        getConfig() {
-          return Promise.resolve({change: {}});
-        },
-        getLoggedIn() {
-          return Promise.resolve(false);
-        },
-        getProjectConfig() {
-          return Promise.resolve({});
-        },
-        getDiffChangeDetail() {
-          return Promise.resolve({});
-        },
-        getChangeFiles() {
-          return Promise.resolve({});
-        },
-        saveFileReviewed() {
-          return Promise.resolve();
-        },
-        getDiffComments() {
-          return Promise.resolve({});
-        },
-        getDiffRobotComments() {
-          return Promise.resolve({});
-        },
-        getDiffDrafts() {
-          return Promise.resolve({});
-        },
-        getReviewedFiles() {
-          return Promise.resolve([]);
-        },
-      });
+      stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      stubRestApi('getProjectConfig').returns(Promise.resolve({}));
+      getDiffChangeDetailStub = stubRestApi('getDiffChangeDetail').returns(
+          Promise.resolve({}));
+      stubRestApi('getChangeFiles').returns(Promise.resolve({}));
+      stubRestApi('saveFileReviewed').returns(Promise.resolve());
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+      stubRestApi('getPortedComments').returns(Promise.resolve({}));
+      getReviewedFilesStub = stubRestApi('getReviewedFiles').returns(
+          Promise.resolve([]));
+
       element = basicFixture.instantiate();
       element._changeNum = '42';
       element._path = 'some/path.txt';
@@ -135,24 +118,27 @@
       sinon.stub(element.$.commentAPI, 'loadAll').returns(Promise.resolve({
         _comments: {'/COMMIT_MSG': [
           {
+            ...createComment(),
             id: 'c1',
             line: 10,
             patch_set: 2,
-            __commentSide: 'left',
             path: '/COMMIT_MSG',
           }, {
+            ...createComment(),
             id: 'c3',
             line: 10,
             patch_set: 'PARENT',
-            __commentSide: 'left',
             path: '/COMMIT_MSG',
           },
         ]},
         computeCommentThreadCount: () => {},
+        computeCommentsString: () => '',
         computeUnresolvedNum: () => {},
         getPaths: () => {},
-        getCommentsBySideForPath: () => {},
+        getThreadsBySideForFile: () => [],
+        getCommentsForPath: _testOnly_getCommentsForPath,
         findCommentById: _testOnly_findCommentById,
+
       }));
       await element._loadComments();
       await flush();
@@ -176,43 +162,51 @@
         basePatchNum: 1,
         path: '/COMMIT_MSG',
       };
+      element._path = '/COMMIT_MSG';
+      element._patchRange = {};
       return element._paramsChanged.returnValues[0].then(() => {
         assert.isTrue(element.reporting.diffViewDisplayed.calledOnce);
       });
     });
 
-    test('comment route', () => {
-      const initLineOfInterestAndCursorStub =
+    suite('comment route', () => {
+      let initLineOfInterestAndCursorStub; let getUrlStub; let replaceStateStub;
+      setup(() => {
+        initLineOfInterestAndCursorStub =
         sinon.stub(element, '_initLineOfInterestAndCursor');
-      sinon.stub(element, '_getFiles');
-      sinon.stub(element.reporting, 'diffViewDisplayed');
-      sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
-      sinon.spy(element, '_paramsChanged');
-      sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
-        ...createChange(),
-        revisions: createRevisions(11),
-      }));
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        commentLink: true,
-        commentId: 'c1',
-      };
-      sinon.stub(element.$.diffHost, '_commentsChanged');
-      sinon.stub(element, '_getCommentsForPath').returns({
-        left: [{id: 'c1', __commentSide: 'left', line: 10}],
-        right: [{id: 'c2', __commentSide: 'right', line: 11}],
+        getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
+        replaceStateStub = sinon.stub(history, 'replaceState');
+        sinon.stub(element, '_getFiles');
+        sinon.stub(element.reporting, 'diffViewDisplayed');
+        sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+        sinon.spy(element, '_paramsChanged');
+        sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
+          ...createChange(),
+          revisions: createRevisions(11),
+        }));
       });
-      element._change = {
-        ...createChange(),
-        revisions: createRevisions(11),
-      };
-      return element._paramsChanged.returnValues[0].then(() => {
-        assert.isTrue(initLineOfInterestAndCursorStub.
-            calledWithExactly(true));
-        assert.equal(element._focusLineNum, 10);
-        assert.equal(element._patchRange.patchNum, 11);
-        assert.equal(element._patchRange.basePatchNum, 2);
+
+      test('comment url resolves to comment.patch_set vs latest', () => {
+        element.params = {
+          view: GerritNav.View.DIFF,
+          changeNum: '42',
+          commentLink: true,
+          commentId: 'c1',
+        };
+        element._change = {
+          ...createChange(),
+          revisions: createRevisions(11),
+        };
+        return element._paramsChanged.returnValues[0].then(() => {
+          assert.isTrue(initLineOfInterestAndCursorStub.
+              calledWithExactly(true));
+          assert.equal(element._focusLineNum, 10);
+          assert.equal(element._patchRange.patchNum, 11);
+          assert.equal(element._patchRange.basePatchNum, 2);
+          assert.isTrue(replaceStateStub.called);
+          assert.isTrue(getUrlStub.calledWithExactly('42', 'test-project',
+              '/COMMIT_MSG', 11, 2, 10, true));
+        });
       });
     });
 
@@ -232,6 +226,8 @@
         basePatchNum: 1,
         path: '/COMMIT_MSG',
       };
+      element._path = '/COMMIT_MSG';
+      element._patchRange = {};
       return element._paramsChanged.returnValues[0].then(() => {
         assert.isTrue(element._isBlameLoaded);
         assert.isTrue(element._loadBlame.calledOnce);
@@ -257,7 +253,6 @@
             commentLink: true,
             commentId: 'c1',
           };
-          sinon.stub(element.$.diffHost, '_commentsChanged');
           element._change = {
             ...createChange(),
             revisions: createRevisions(11),
@@ -287,7 +282,6 @@
             commentLink: true,
             commentId: 'c3',
           };
-          sinon.stub(element.$.diffHost, '_commentsChanged');
           element._change = {
             ...createChange(),
             revisions: createRevisions(11),
@@ -333,13 +327,11 @@
       sinon.stub(element, '_loadBlame');
       sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
       sinon.spy(element, '_paramsChanged');
-      element.$.restAPI.getDiffChangeDetail.restore();
-      sinon.stub(element.$.restAPI, 'getDiffChangeDetail')
-          .returns(
-              Promise.resolve({
-                ...createChange(),
-                revisions: createRevisions(11),
-              }));
+      getDiffChangeDetailStub.returns(
+          Promise.resolve({
+            ...createChange(),
+            revisions: createRevisions(11),
+          }));
       element._patchRange = {
         patchNum: 2,
         basePatchNum: 1,
@@ -473,11 +465,53 @@
       assert.equal(element._setReviewed.lastCall.args[0], true);
     });
 
-    test('shift+x shortcut expands all diff context', () => {
-      const expandStub = sinon.stub(element.$.diffHost, 'expandAllContext');
+    test('moveToNextCommentThread navigates to next file', () => {
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      const diffChangeStub = sinon.stub(element, '_navigateToChange');
+      sinon.stub(element.$.cursor, 'isAtEnd').returns(true);
+      element._changeNum = '42';
+      const comment = {
+        'wheatley.md': [{
+          ...createComment(),
+          patch_set: 10,
+          line: 21,
+        }],
+      };
+      element._changeComments = new ChangeComments(comment);
+      element._patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 10,
+      };
+      element._change = {
+        _number: 42,
+        revisions: {
+          a: {_number: 10, commit: {parents: []}},
+        },
+      };
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
+      element._path = 'glados.txt';
+      element.changeViewState.selectedFileIndex = 1;
+      element._loggedIn = true;
+
+      MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+      flush();
+      assert.isTrue(diffNavStub.calledWithExactly(
+          element._change, 'wheatley.md', 10, PARENT, 21));
+
+      element._path = 'wheatley.md'; // navigated to next file
+
+      MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+      flush();
+
+      assert.isTrue(diffChangeStub.called);
+    });
+
+    test('shift+x shortcut toggles all diff context', () => {
+      const toggleStub = sinon.stub(element.$.diffHost, 'toggleAllContext');
       MockInteractions.pressAndReleaseKeyOn(element, 88, 'shift', 'x');
       flush();
-      assert.isTrue(expandStub.called);
+      assert.isTrue(toggleStub.called);
     });
 
     test('diff against base', () => {
@@ -589,6 +623,76 @@
       assert.isNotOk(args[3]);
     });
 
+    test('A fires an error event when not logged in', done => {
+      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();
+      });
+    });
+
+    test('A navigates to change with logged in', done => {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 5,
+        patchNum: 10,
+      };
+      element._change = {
+        _number: 42,
+        revisions: {
+          a: {_number: 10, commit: {parents: []}},
+          b: {_number: 5, 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, 10,
+            5), 'Should navigate to /c/42/5..10');
+        assert.isFalse(loggedInErrorSpy.called);
+        done();
+      });
+    });
+
+    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('keyboard shortcuts with patch range', () => {
       element._changeNum = '42';
       element._patchRange = {
@@ -609,19 +713,6 @@
       const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
       const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
-          'should only work when the user is logged in.');
-      assert.isNull(window.sessionStorage.getItem(
-          'changeView.showReplyDialog'));
-
-      element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(element.changeViewState.showReplyDialog);
-
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
-          5), 'Should navigate to /c/42/5..10');
-
       MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
       assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
           5), 'Should navigate to /c/42/5..10');
@@ -629,14 +720,14 @@
       MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert.isTrue(element._loading);
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'wheatley.md', 10, 5),
+          'wheatley.md', 10, 5, undefined),
       'Should navigate to /c/42/5..10/wheatley.md');
       element._path = 'wheatley.md';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert.isTrue(element._loading);
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'glados.txt', 10, 5),
+          'glados.txt', 10, 5, undefined),
       'Should navigate to /c/42/5..10/glados.txt');
       element._path = 'glados.txt';
 
@@ -646,7 +737,8 @@
           element._change,
           'chell.go',
           10,
-          5),
+          5,
+          undefined),
       'Should navigate to /c/42/5..10/chell.go');
       element._path = 'chell.go';
 
@@ -681,32 +773,19 @@
       const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
       const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
-          'should only work when the user is logged in.');
-      assert.isNull(window.sessionStorage.getItem(
-          'changeView.showReplyDialog'));
-
-      element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(element.changeViewState.showReplyDialog);
-
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
-          PARENT), 'Should navigate to /c/42/1');
-
       MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
       assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
           PARENT), 'Should navigate to /c/42/1');
 
       MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'wheatley.md', 1, PARENT),
+          'wheatley.md', 1, PARENT, undefined),
       'Should navigate to /c/42/1/wheatley.md');
       element._path = 'wheatley.md';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'glados.txt', 1, PARENT),
+          'glados.txt', 1, PARENT, undefined),
       'Should navigate to /c/42/1/glados.txt');
       element._path = 'glados.txt';
 
@@ -715,7 +794,8 @@
           element._change,
           'chell.go',
           1,
-          PARENT), 'Should navigate to /c/42/1/chell.go');
+          PARENT,
+          undefined), 'Should navigate to /c/42/1/chell.go');
       element._path = 'chell.go';
 
       changeNavStub.reset();
@@ -820,10 +900,7 @@
     }
 
     test('edit visible only when logged and status NEW', async () => {
-      for (const changeStatus in ChangeStatus) {
-        if (!ChangeStatus.hasOwnProperty(changeStatus)) {
-          continue;
-        }
+      for (const changeStatus of Object.keys(ChangeStatus)) {
         assert.isFalse(await isEditVisibile({loggedIn: false, changeStatus}),
             `loggedIn: false, changeStatus: ${changeStatus}`);
 
@@ -900,44 +977,6 @@
       assert.isTrue(overlayOpenStub.called);
     });
 
-    test('_computeCommentString', done => {
-      const path = '/test';
-      element.$.commentAPI.loadAll().then(comments => {
-        const commentThreadCountStub =
-            sinon.stub(comments, 'computeCommentThreadCount');
-        const unresolvedCountStub =
-            sinon.stub(comments, 'computeUnresolvedNum');
-        commentThreadCountStub.withArgs({patchNum: 1, path}).returns(0);
-        commentThreadCountStub.withArgs({patchNum: 2, path}).returns(1);
-        commentThreadCountStub.withArgs({patchNum: 3, path}).returns(2);
-        commentThreadCountStub.withArgs({patchNum: 4, path}).returns(0);
-        unresolvedCountStub.withArgs({patchNum: 1, path}).returns(1);
-        unresolvedCountStub.withArgs({patchNum: 2, path}).returns(0);
-        unresolvedCountStub.withArgs({patchNum: 3, path}).returns(2);
-        unresolvedCountStub.withArgs({patchNum: 4, path}).returns(0);
-
-        assert.equal(element._computeCommentString(comments, 1, path, {}),
-            '1 unresolved');
-        assert.equal(
-            element._computeCommentString(comments, 2, path, {status: 'M'}),
-            '1 comment');
-        assert.equal(
-            element._computeCommentString(comments, 2, path, {status: 'U'}),
-            'no changes, 1 comment');
-        assert.equal(
-            element._computeCommentString(comments, 3, path, {status: 'A'}),
-            '2 comments, 2 unresolved');
-        assert.equal(
-            element._computeCommentString(
-                comments, 4, path, {status: 'M'}
-            ), '');
-        assert.equal(
-            element._computeCommentString(comments, 4, path, {status: 'U'}),
-            'no changes');
-        done();
-      });
-    });
-
     suite('url params', () => {
       setup(() => {
         sinon.stub(element, '_getFiles');
@@ -957,9 +996,6 @@
           basePatchNum: PARENT,
           patchNum: 10,
         };
-        // computeCommentThreadCount is an empty function hence stubbing
-        // function that depends on it's return value
-        sinon.stub(element, '_computeCommentString').returns('');
         element._change = {_number: 42};
         element._files = getFilesFromFileList(
             ['chell.go', 'glados.txt', 'wheatley.md',
@@ -971,28 +1007,43 @@
             mobileText: 'chell.go',
             value: 'chell.go',
             bottomText: '',
+            file: {
+              __path: 'chell.go',
+            },
           }, {
             text: 'glados.txt',
             mobileText: 'glados.txt',
             value: 'glados.txt',
             bottomText: '',
+            file: {
+              __path: 'glados.txt',
+            },
           }, {
             text: 'wheatley.md',
             mobileText: 'wheatley.md',
             value: 'wheatley.md',
             bottomText: '',
+            file: {
+              __path: 'wheatley.md',
+            },
           },
           {
             text: 'Commit message',
             mobileText: 'Commit message',
             value: '/COMMIT_MSG',
             bottomText: '',
+            file: {
+              __path: '/COMMIT_MSG',
+            },
           },
           {
             text: 'Merge list',
             mobileText: 'Merge list',
             value: '/MERGE_LIST',
             bottomText: '',
+            file: {
+              __path: '/MERGE_LIST',
+            },
           },
         ];
 
@@ -1027,10 +1078,10 @@
         assert.equal(linkEls[0].getAttribute('href'),
             '42-glados.txt-10-PARENT');
         assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-        assert.isFalse(linkEls[2].hasAttribute('href'));
+        assert.equal(linkEls[2].getAttribute('href'), '42-undefined-undefined');
         element._path = 'chell.go';
         flush();
-        assert.isFalse(linkEls[0].hasAttribute('href'));
+        assert.equal(linkEls[0].getAttribute('href'), '42-undefined-undefined');
         assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
         assert.equal(linkEls[2].getAttribute('href'),
             '42-glados.txt-10-PARENT');
@@ -1068,10 +1119,11 @@
         flush();
         assert.equal(linkEls[0].getAttribute('href'), '42-glados.txt-10-5');
         assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
-        assert.isFalse(linkEls[2].hasAttribute('href'));
+        assert.equal(linkEls[2].getAttribute('href'), '42-10-5');
         element._path = 'chell.go';
         flush();
-        assert.isFalse(linkEls[0].hasAttribute('href'));
+        assert.equal(linkEls[0].getAttribute('href'),
+            '42-10-5');
         assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
         assert.equal(linkEls[2].getAttribute('href'), '42-glados.txt-10-5');
       });
@@ -1175,7 +1227,7 @@
     test('file review status with edit loaded', () => {
       const saveReviewedStub = sinon.stub(element, '_saveReviewedState');
 
-      element._patchRange = {patchNum: SPECIAL_PATCH_SET_NUM.EDIT};
+      element._patchRange = {patchNum: EditPatchSetNum};
       flush();
 
       assert.isTrue(element._editMode);
@@ -1232,7 +1284,7 @@
       const prefsPromise = new Promise(resolve => {
         resolvePrefs = resolve;
       });
-      sinon.stub(element.$.restAPI, 'getPreferences')
+      stubRestApi('getPreferences')
           .callsFake(() => prefsPromise);
 
       // Attach a new gr-diff-view so we can intercept the preferences fetch.
@@ -1449,7 +1501,6 @@
         await flush();
       });
       test('empty', () => {
-        sinon.stub(element, '_getCommentsForPath');
         sinon.stub(element, '_getPaths').returns(new Map());
         element._initPatchRange();
         assert.equal(Object.keys(element._commentMap).length, 0);
@@ -1461,7 +1512,6 @@
           'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
           'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
         });
-        sinon.stub(element, '_getCommentsForPath').returns({meta: {}});
         element._changeNum = '42';
         element._patchRange = {
           basePatchNum: 3,
@@ -1623,10 +1673,7 @@
 
     test('_getReviewedStatus', () => {
       const promises = [];
-      element.$.restAPI.getReviewedFiles.restore();
-
-      sinon.stub(element.$.restAPI, 'getReviewedFiles')
-          .returns(Promise.resolve(['path']));
+      getReviewedFilesStub.returns(Promise.resolve(['path']));
 
       promises.push(element._getReviewedStatus(true, null, null, 'path')
           .then(reviewed => assert.isFalse(reviewed)));
@@ -1681,7 +1728,7 @@
         element._patchRange = {patchNum: 1};
         // Reviewed checkbox should be shown.
         assert.isTrue(isVisible(element.$.reviewed));
-        element.set('_patchRange.patchNum', SPECIAL_PATCH_SET_NUM.EDIT);
+        element.set('_patchRange.patchNum', EditPatchSetNum);
         flush();
 
         assert.isFalse(isVisible(element.$.reviewed));
@@ -1690,7 +1737,7 @@
 
     test('_paramsChanged sets in projectLookup', () => {
       sinon.stub(element, '_initLineOfInterestAndCursor');
-      const setStub = sinon.stub(element.$.restAPI, 'setInProjectLookup');
+      const setStub = stubRestApi('setInProjectLookup');
       element._paramsChanged({
         view: GerritNav.View.DIFF,
         changeNum: 101,
@@ -1867,18 +1914,17 @@
         'file1.txt': {},
         'a/b/test.c': {},
       };
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({change: {}}); },
-        getLoggedIn() { return Promise.resolve(false); },
-        getProjectConfig() { return Promise.resolve({}); },
-        getDiffChangeDetail() { return Promise.resolve({}); },
-        getChangeFiles() { return Promise.resolve(changedFiles); },
-        saveFileReviewed() { return Promise.resolve(); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-        getReviewedFiles() { return Promise.resolve([]); },
-      });
+      stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
+
+      stubRestApi('getProjectConfig').returns(Promise.resolve({}));
+      stubRestApi('getDiffChangeDetail').returns(Promise.resolve({}));
+      stubRestApi('getChangeFiles').returns(Promise.resolve(changedFiles));
+      stubRestApi('saveFileReviewed').returns(Promise.resolve());
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+      stubRestApi('getReviewedFiles').returns(
+          Promise.resolve([]));
       element = basicFixture.instantiate();
       element._changeNum = '42';
       return element._loadComments();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
index 588b9d1..ba6fe7e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {BLANK_LINE, GrDiffLine, GrDiffLineType} from './gr-diff-line';
-import {Side} from '../../../constants/constants';
+import {LineRange} from '../../../api/diff';
 
 export enum GrDiffGroupType {
   /** Unchanged context. */
@@ -33,20 +33,6 @@
   right: GrDiffLine;
 }
 
-interface Range {
-  start: number | null;
-  end: number | null;
-}
-
-export interface GrDiffGroupRange {
-  left: Range;
-  right: Range;
-}
-
-export function rangeBySide(range: GrDiffGroupRange, side: Side): Range {
-  return side === Side.LEFT ? range.left : range.right;
-}
-
 /**
  * Hides lines in the given range behind a context control group.
  *
@@ -83,16 +69,17 @@
 
   const numHidden = hiddenEnd - hiddenStart;
 
-  // Only collapse if there is more than 1 line to be hidden.
-  if (numHidden > 1) {
+  // Showing a context control row for less than 4 lines does not make much,
+  // because then that row would consume as much space as the collapsed code.
+  if (numHidden > 3) {
     if (hiddenStart) {
       [before, hidden] = _splitCommonGroups(hidden, hiddenStart);
     }
     if (hiddenEnd) {
       let beforeLength = 0;
       if (before.length > 0) {
-        const beforeStart = before[0].lineRange.left.start || 0;
-        const beforeEnd = before[before.length - 1].lineRange.left.end || 0;
+        const beforeStart = before[0].lineRange.left.start_line;
+        const beforeEnd = before[before.length - 1].lineRange.left.end_line;
         beforeLength = beforeEnd - beforeStart + 1;
       }
       [hidden, after] = _splitCommonGroups(hidden, hiddenEnd - beforeLength);
@@ -136,8 +123,8 @@
     // group will in the future mean load more data - and therefore we want to
     // fire an event when user wants to do it.
     const closerToStartThanEnd =
-      leftSplit - (group.lineRange.left.start || 0) <
-      (group.lineRange.right.end || 0) - leftSplit;
+      leftSplit - group.lineRange.left.start_line <
+      group.lineRange.right.end_line - leftSplit;
     if (closerToStartThanEnd) {
       afterSplit = group;
     } else {
@@ -190,18 +177,18 @@
   split: number
 ): GrDiffGroup[][] {
   if (groups.length === 0) return [[], []];
-  const leftSplit = (groups[0].lineRange.left.start || 0) + split;
-  const rightSplit = (groups[0].lineRange.right.start || 0) + split;
+  const leftSplit = groups[0].lineRange.left.start_line + split;
+  const rightSplit = groups[0].lineRange.right.start_line + split;
 
   const beforeGroups = [];
   const afterGroups = [];
   for (const group of groups) {
     const isCompletelyBefore =
-      (group.lineRange.left.end || 0) < leftSplit ||
-      (group.lineRange.right.end || 0) < rightSplit;
+      group.lineRange.left.end_line < leftSplit ||
+      group.lineRange.right.end_line < rightSplit;
     const isCompletelyAfter =
-      leftSplit <= (group.lineRange.left.start || 0) ||
-      rightSplit <= (group.lineRange.right.start || 0);
+      leftSplit <= group.lineRange.left.start_line ||
+      rightSplit <= group.lineRange.right.start_line;
     if (isCompletelyBefore) {
       beforeGroups.push(group);
     } else if (isCompletelyAfter) {
@@ -237,8 +224,6 @@
 
   dueToRebase = false;
 
-  dueToMove = false;
-
   /**
    * True means all changes in this line are whitespace changes that should
    * not be highlighted as changed as per the user settings.
@@ -264,9 +249,17 @@
   skip?: number;
 
   /** Both start and end line are inclusive. */
-  lineRange: GrDiffGroupRange = {
-    left: {start: null, end: null},
-    right: {start: null, end: null},
+  lineRange = {
+    left: {start_line: 0, end_line: 0} as LineRange,
+    right: {start_line: 0, end_line: 0} as LineRange,
+  };
+
+  moveDetails?: {
+    changed: boolean;
+    range?: {
+      start: number;
+      end: number;
+    };
   };
 
   /**
@@ -331,22 +324,24 @@
   }
 
   _updateRange(line: GrDiffLine) {
-    if (line.beforeNumber === 'FILE' || line.afterNumber === 'FILE') {
+    if (
+      line.beforeNumber === 'FILE' ||
+      line.afterNumber === 'FILE' ||
+      line.beforeNumber === 'LOST' ||
+      line.afterNumber === 'LOST'
+    ) {
       return;
     }
 
     if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
       if (
-        this.lineRange.right.start === null ||
-        line.afterNumber < this.lineRange.right.start
+        this.lineRange.right.start_line === 0 ||
+        line.afterNumber < this.lineRange.right.start_line
       ) {
-        this.lineRange.right.start = line.afterNumber;
+        this.lineRange.right.start_line = line.afterNumber;
       }
-      if (
-        this.lineRange.right.end === null ||
-        line.afterNumber > this.lineRange.right.end
-      ) {
-        this.lineRange.right.end = line.afterNumber;
+      if (line.afterNumber > this.lineRange.right.end_line) {
+        this.lineRange.right.end_line = line.afterNumber;
       }
     }
 
@@ -355,16 +350,13 @@
       line.type === GrDiffLineType.BOTH
     ) {
       if (
-        this.lineRange.left.start === null ||
-        line.beforeNumber < this.lineRange.left.start
+        this.lineRange.left.start_line === 0 ||
+        line.beforeNumber < this.lineRange.left.start_line
       ) {
-        this.lineRange.left.start = line.beforeNumber;
+        this.lineRange.left.start_line = line.beforeNumber;
       }
-      if (
-        this.lineRange.left.end === null ||
-        line.beforeNumber > this.lineRange.left.end
-      ) {
-        this.lineRange.left.end = line.beforeNumber;
+      if (line.beforeNumber > this.lineRange.left.end_line) {
+        this.lineRange.left.end_line = line.beforeNumber;
       }
     }
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
index 3423834..4c7d346 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
@@ -32,8 +32,8 @@
     assert.deepEqual(group.adds, [l1, l2]);
     assert.deepEqual(group.removes, [l3]);
     assert.deepEqual(group.lineRange, {
-      left: {start: 64, end: 64},
-      right: {start: 128, end: 129},
+      left: {start_line: 64, end_line: 64},
+      right: {start_line: 128, end_line: 129},
     });
 
     let pairs = group.getSideBySidePairs();
@@ -66,8 +66,8 @@
     assert.deepEqual(group.removes, []);
 
     assert.deepEqual(group.lineRange, {
-      left: {start: 64, end: 66},
-      right: {start: 128, end: 130},
+      left: {start_line: 64, end_line: 66},
+      right: {start_line: 128, end_line: 130},
     });
 
     let pairs = group.getSideBySidePairs();
@@ -122,17 +122,19 @@
           new GrDiffLine(GrDiffLineType.ADD, 0, 11),
           new GrDiffLine(GrDiffLineType.REMOVE, 10),
           new GrDiffLine(GrDiffLineType.ADD, 0, 12),
+          new GrDiffLine(GrDiffLineType.REMOVE, 11),
+          new GrDiffLine(GrDiffLineType.ADD, 0, 13),
         ]),
         new GrDiffGroup(GrDiffGroupType.BOTH, [
-          new GrDiffLine(GrDiffLineType.BOTH, 11, 13),
           new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
           new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
+          new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
         ]),
       ];
     });
 
     test('hides hidden groups in context control', () => {
-      const collapsedGroups = hideInContextControl(groups, 3, 6);
+      const collapsedGroups = hideInContextControl(groups, 3, 7);
       assert.equal(collapsedGroups.length, 3);
 
       assert.equal(collapsedGroups[0], groups[0]);
@@ -145,7 +147,7 @@
     });
 
     test('splits partially hidden groups', () => {
-      const collapsedGroups = hideInContextControl(groups, 4, 7);
+      const collapsedGroups = hideInContextControl(groups, 4, 8);
       assert.equal(collapsedGroups.length, 4);
       assert.equal(collapsedGroups[0], groups[0]);
 
@@ -182,8 +184,8 @@
         const skipGroup = new GrDiffGroup(GrDiffGroupType.BOTH);
         skipGroup.skip = 60;
         skipGroup.lineRange = {
-          left: {start: 8, end: 67},
-          right: {start: 10, end: 69},
+          left: {start_line: 8, end_line: 67},
+          right: {start_line: 10, end_line: 69},
         };
         groups = [
           new GrDiffGroup(GrDiffGroupType.BOTH, [
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts
index 2d80213..2927101 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts
@@ -15,17 +15,17 @@
  * limitations under the License.
  */
 
+import {
+  GrDiffLine as GrDiffLineApi,
+  GrDiffLineType,
+  LineNumber,
+} from '../../../api/diff';
+
+export {GrDiffLineType, LineNumber};
+
 export const FILE = 'FILE';
-export type LineNumber = number | 'FILE';
 
-export enum GrDiffLineType {
-  ADD = 'add',
-  BOTH = 'both',
-  BLANK = 'blank',
-  REMOVE = 'remove',
-}
-
-export class GrDiffLine {
+export class GrDiffLine implements GrDiffLineApi {
   constructor(
     readonly type: GrDiffLineType,
     public beforeNumber: LineNumber = 0,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
index 8984dc8..0ca929a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
@@ -17,6 +17,7 @@
 
 import {CommentRange} from '../../../types/common';
 import {FILE, LineNumber} from './gr-diff-line';
+import {Side} from '../../../constants/constants';
 
 /**
  * Compare two ranges. Either argument may be falsy, but will only return
@@ -38,11 +39,64 @@
   );
 }
 
+export function isLongCommentRange(range: CommentRange): boolean {
+  return range.end_line - range.start_line > 10;
+}
+
 export function getLineNumber(lineEl?: Element | null): LineNumber | null {
   if (!lineEl) return null;
   const lineNumberStr = lineEl.getAttribute('data-value');
   if (!lineNumberStr) return null;
   if (lineNumberStr === FILE) return FILE;
+  if (lineNumberStr === 'LOST') return 'LOST';
   const lineNumber = Number(lineNumberStr);
   return Number.isInteger(lineNumber) ? lineNumber : null;
 }
+
+export function getLine(threadEl: HTMLElement): LineNumber {
+  const lineAtt = threadEl.getAttribute('line-num');
+  if (lineAtt === 'LOST') return lineAtt;
+  if (!lineAtt || lineAtt === 'FILE') return FILE;
+  const line = Number(lineAtt);
+  if (isNaN(line)) throw new Error(`cannot parse line number: ${lineAtt}`);
+  if (line < 1) throw new Error(`line number smaller than 1: ${line}`);
+  return line;
+}
+
+export function getSide(threadEl: HTMLElement): Side | undefined {
+  // TODO(dhruvsri): Remove check for comment-side once all users of gr-diff
+  // start setting diff-side
+  const sideAtt =
+    threadEl.getAttribute('diff-side') || threadEl.getAttribute('comment-side');
+  if (!sideAtt) {
+    console.warn('comment thread without side');
+    return undefined;
+  }
+  if (sideAtt !== Side.LEFT && sideAtt !== Side.RIGHT)
+    throw Error(`unexpected value for side: ${sideAtt}`);
+  return sideAtt as Side;
+}
+
+export function getRange(threadEl: HTMLElement): CommentRange | undefined {
+  const rangeAtt = threadEl.getAttribute('range');
+  if (!rangeAtt) return undefined;
+  const range = JSON.parse(rangeAtt) as CommentRange;
+  if (!range.start_line) throw new Error(`invalid range: ${rangeAtt}`);
+  return range;
+}
+
+// TODO: This type should be exposed to gr-diff clients in a separate type file.
+// For Gerrit these are instances of GrCommentThread, but other gr-diff users
+// have different HTML elements in use for comment threads.
+// TODO: Also document the required HTML attributes that thread elements must
+// have, e.g. 'diff-side', 'range', 'line-num', 'data-value'.
+export interface GrDiffThreadElement extends HTMLElement {
+  rootId: string;
+}
+
+export function isThreadEl(node: Node): node is GrDiffThreadElement {
+  return (
+    node.nodeType === Node.ELEMENT_NODE &&
+    (node as Element).classList.contains('comment-thread')
+  );
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
index e57b336..ae51246 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -16,32 +16,35 @@
  */
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
 import '../gr-diff-builder/gr-diff-builder-element';
 import '../gr-diff-highlight/gr-diff-highlight';
 import '../gr-diff-selection/gr-diff-selection';
 import '../gr-syntax-themes/gr-syntax-theme';
 import '../gr-ranged-comment-themes/gr-ranged-comment-theme';
+import '../gr-ranged-comment-hint/gr-ranged-comment-hint';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {htmlTemplate} from './gr-diff_html';
-import {FILE, LineNumber} from './gr-diff-line';
-import {getLineNumber, rangesEqual} from './gr-diff-utils';
-import {getHiddenScroll} from '../../../scripts/hiddenscroll';
-import {isMergeParent, patchNumEquals} from '../../../utils/patch-set-util';
-import {customElement, observe, property} from '@polymer/decorators';
+import {LineNumber} from './gr-diff-line';
 import {
-  BlameInfo,
-  CommentRange,
+  getLine,
+  getLineNumber,
+  getRange,
+  getSide,
+  GrDiffThreadElement,
+  isLongCommentRange,
+  isThreadEl,
+  rangesEqual,
+} from './gr-diff-utils';
+import {getHiddenScroll} from '../../../scripts/hiddenscroll';
+import {customElement, observe, property} from '@polymer/decorators';
+import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
+import {
   DiffInfo,
   DiffPreferencesInfo,
   DiffPreferencesInfoKey,
-  EditPatchSetNum,
-  ImageInfo,
-  ParentPatchSetNum,
-  PatchRange,
-} from '../../../types/common';
+} from '../../../types/diff';
 import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
 import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 import {
@@ -50,41 +53,33 @@
   PolymerDomWrapper,
 } from '../../../types/types';
 import {CommentRangeLayer} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
-import {DiffViewMode, Side} from '../../../constants/constants';
+import {
+  createDefaultDiffPrefs,
+  DiffViewMode,
+  Side,
+} from '../../../constants/constants';
 import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
 import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {AbortStop} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {MovedLinkClickedEvent} from '../../../types/events';
 import {getContentEditableRange} from '../../../utils/safari-selection-util';
 
-import {isSafari} from '../../../utils/dom-util';
+import {
+  CreateCommentEventDetail as CreateCommentEventDetailApi,
+  RenderPreferences,
+} 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';
 
 const NO_NEWLINE_BASE = 'No newline at end of base file.';
 const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
 
 const LARGE_DIFF_THRESHOLD_LINES = 10000;
 const FULL_CONTEXT = -1;
-const LIMITED_CONTEXT = 10;
-
-function getSide(threadEl: GrCommentThread): Side {
-  const sideAtt = threadEl.getAttribute('comment-side');
-  if (!sideAtt) throw Error('comment thread without side');
-  if (sideAtt !== 'left' && sideAtt !== 'right')
-    throw Error(`unexpected value for side: ${sideAtt}`);
-  return sideAtt as Side;
-}
-
-function isThreadEl(node: Node): node is GrCommentThread {
-  return (
-    node.nodeType === Node.ELEMENT_NODE &&
-    (node as Element).classList.contains('comment-thread')
-  );
-}
-
-// TODO(TS): Replace by proper GrCommentThread once converted.
-type GrCommentThread = PolymerElement & {
-  rootId: string;
-  range: CommentRange;
-};
 
 const COMMIT_MSG_PATH = '/COMMIT_MSG';
 /**
@@ -96,8 +91,6 @@
  */
 const COMMIT_MSG_LINE_LENGTH = 72;
 
-const RENDER_DIFF_TABLE_DEBOUNCE_NAME = 'renderDiffTable';
-
 export interface LineOfInterest {
   number: number;
   leftSide: boolean;
@@ -111,10 +104,12 @@
   };
 }
 
+export interface CreateCommentEventDetail extends CreateCommentEventDetailApi {
+  path: string;
+}
+
 @customElement('gr-diff')
-export class GrDiff extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiff extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -158,15 +153,15 @@
   @property({type: Boolean})
   noAutoRender = false;
 
-  @property({type: Object})
-  patchRange?: PatchRange;
-
   @property({type: String, observer: '_pathObserver'})
   path?: string;
 
   @property({type: Object, observer: '_prefsObserver'})
   prefs?: DiffPreferencesInfo;
 
+  @property({type: Object, observer: '_renderPrefsChanged'})
+  renderPrefs?: RenderPreferences;
+
   @property({type: Boolean})
   displayLine = false;
 
@@ -182,6 +177,10 @@
   @property({type: Array})
   _commentRanges: CommentRangeLayer[] = [];
 
+  // explicitly highlight a range if it is not associated with any comment
+  @property({type: Object})
+  highlightRange?: CommentRange;
+
   @property({type: Array})
   coverageRanges: CoverageRange[] = [];
 
@@ -194,9 +193,18 @@
   @property({type: Object})
   lineOfInterest?: LineOfInterest;
 
-  /** True when diff is changed, until the content is done rendering. */
-  @property({type: Boolean})
-  _loading = false;
+  /**
+   * True when diff is changed, until the content is done rendering.
+   *
+   * This is readOnly, meaning one can listen for the loading-changed event, but
+   * not write to it from the outside. Code in this class should use the
+   * "private" _setLoading method.
+   */
+  @property({type: Boolean, notify: true, readOnly: true})
+  loading!: boolean;
+
+  // Polymer generated when setting readOnly above.
+  _setLoading!: (loading: boolean) => void;
 
   @property({type: Boolean})
   loggedIn = false;
@@ -205,7 +213,7 @@
   diff?: DiffInfo;
 
   @property({type: Array, computed: '_computeDiffHeaderItems(diff.*)'})
-  _diffHeaderItems: unknown[] = [];
+  _diffHeaderItems: string[] = [];
 
   @property({type: String})
   _diffTableClass = '';
@@ -251,8 +259,8 @@
   @property({type: Boolean})
   showNewlineWarningRight = false;
 
-  @property({type: Boolean})
-  useNewContextControls = false;
+  @property({type: String, observer: '_useNewImageDiffUiObserver'})
+  useNewImageDiffUi = false;
 
   @property({
     type: String,
@@ -283,26 +291,35 @@
   @property({type: Array})
   layers?: DiffLayer[];
 
-  /** @override */
-  created() {
-    super.created();
+  @property({type: Boolean})
+  isAttached = false;
+
+  private renderDiffTableTask?: DelayedTask;
+
+  constructor() {
+    super();
+    this._setLoading(true);
     this.addEventListener('create-range-comment', (e: Event) =>
       this._handleCreateRangeComment(e as CustomEvent)
     );
     this.addEventListener('render-content', () => this._handleRenderContent());
+    this.addEventListener('moved-link-clicked', e => this._movedLinkClicked(e));
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._observeNodes();
+    this.isAttached = true;
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
+    this.isAttached = false;
+    this.renderDiffTableTask?.cancel();
     this._unobserveIncrementalNodes();
     this._unobserveNodes();
+    super.disconnectedCallback();
   }
 
   showNoChangeMessage(
@@ -322,36 +339,34 @@
   }
 
   @observe('loggedIn', 'isAttached')
-  _enableSelectionObserver(loggedIn: boolean, isAttached?: boolean) {
-    // Polymer 2: check for undefined
-    if ([loggedIn, isAttached].includes(undefined)) {
-      return;
-    }
-
+  _enableSelectionObserver(loggedIn: boolean, isAttached: boolean) {
     if (loggedIn && isAttached) {
-      this.listen(document, 'selectionchange', '_handleSelectionChange');
-      this.listen(document, 'mouseup', '_handleMouseUp');
+      document.addEventListener('selectionchange', this.handleSelectionChange);
+      document.addEventListener('mouseup', this.handleMouseUp);
     } else {
-      this.unlisten(document, 'selectionchange', '_handleSelectionChange');
-      this.unlisten(document, 'mouseup', '_handleMouseUp');
+      document.removeEventListener(
+        'selectionchange',
+        this.handleSelectionChange
+      );
+      document.removeEventListener('mouseup', this.handleMouseUp);
     }
   }
 
-  _handleSelectionChange() {
+  private readonly handleSelectionChange = () => {
     // Because of shadow DOM selections, we handle the selectionchange here,
     // and pass the shadow DOM selection into gr-diff-highlight, where the
     // corresponding range is determined and normalized.
     const selection = this._getShadowOrDocumentSelection();
     this.$.highlights.handleSelectionChange(selection, false);
-  }
+  };
 
-  _handleMouseUp() {
+  private readonly handleMouseUp = () => {
     // To handle double-click outside of text creating comments, we check on
     // mouse-up if there's a selection that just covers a line change. We
     // can't do that on selection change since the user may still be dragging.
     const selection = this._getShadowOrDocumentSelection();
     this.$.highlights.handleSelectionChange(selection, true);
-  }
+  };
 
   /** Gets the current selection, preferring the shadow DOM selection. */
   _getShadowOrDocumentSelection() {
@@ -371,24 +386,25 @@
       const addedThreadEls = info.addedNodes.filter(isThreadEl);
       const removedThreadEls = info.removedNodes.filter(isThreadEl);
       this._updateRanges(addedThreadEls, removedThreadEls);
-      this._redispatchHoverEvents(addedThreadEls);
+      addedThreadEls.forEach(threadEl =>
+        this._redispatchHoverEvents(threadEl, threadEl)
+      );
     });
   }
 
   // TODO(brohlfs): Rewrite gr-diff to be agnostic of GrCommentThread, because
   // other users of gr-diff may use different comment widgets.
   _updateRanges(
-    addedThreadEls: GrCommentThread[],
-    removedThreadEls: GrCommentThread[]
+    addedThreadEls: GrDiffThreadElement[],
+    removedThreadEls: GrDiffThreadElement[]
   ) {
     function commentRangeFromThreadEl(
-      threadEl: GrCommentThread
+      threadEl: GrDiffThreadElement
     ): CommentRangeLayer | undefined {
       const side = getSide(threadEl);
-
-      const rangeAtt = threadEl.getAttribute('range');
-      if (!rangeAtt) return undefined;
-      const range = JSON.parse(rangeAtt) as CommentRange;
+      if (!side) return undefined;
+      const range = getRange(threadEl);
+      if (!range) return undefined;
 
       return {side, range, hovering: false, rootId: threadEl.rootId};
     }
@@ -412,13 +428,20 @@
     if (addedCommentRanges && addedCommentRanges.length) {
       this.push('_commentRanges', ...addedCommentRanges);
     }
+    if (this.highlightRange) {
+      this.push('_commentRanges', {
+        side: Side.RIGHT,
+        range: this.highlightRange,
+        hovering: true,
+        rootId: '',
+      });
+    }
   }
 
   /**
    * The key locations based on the comments and line of interests,
    * where lines should not be collapsed.
    *
-   * @return
    */
   _computeKeyLocations() {
     const keyLocations: KeyLocations = {left: {}, right: {}};
@@ -432,12 +455,13 @@
 
     for (const threadEl of threadEls) {
       const side = getSide(threadEl);
-      const lineNum = Number(threadEl.getAttribute('line-num')) || FILE;
-      const commentRange = threadEl.range || {};
+      if (!side) continue;
+      const lineNum = getLine(threadEl);
+      const commentRange = getRange(threadEl);
       keyLocations[side][lineNum] = true;
       // Add start_line as well if exists,
       // the being and end of the range should not be collapsed.
-      if (commentRange.start_line) {
+      if (commentRange?.start_line) {
         keyLocations[side][commentRange.start_line] = true;
       }
     }
@@ -445,41 +469,32 @@
   }
 
   // Dispatch events that are handled by the gr-diff-highlight.
-  _redispatchHoverEvents(addedThreadEls: GrCommentThread[]) {
-    for (const threadEl of addedThreadEls) {
-      threadEl.addEventListener('mouseenter', () => {
-        threadEl.dispatchEvent(
-          new CustomEvent('comment-thread-mouseenter', {
-            bubbles: true,
-            composed: true,
-          })
-        );
-      });
-      threadEl.addEventListener('mouseleave', () => {
-        threadEl.dispatchEvent(
-          new CustomEvent('comment-thread-mouseleave', {
-            bubbles: true,
-            composed: true,
-          })
-        );
-      });
-    }
+  _redispatchHoverEvents(hoverEl: HTMLElement, threadEl: GrDiffThreadElement) {
+    hoverEl.addEventListener('mouseenter', () => {
+      fireEvent(threadEl, 'comment-thread-mouseenter');
+    });
+    hoverEl.addEventListener('mouseleave', () => {
+      fireEvent(threadEl, 'comment-thread-mouseleave');
+    });
   }
 
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
     this.$.diffBuilder.cancel();
-    this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
+    this.renderDiffTableTask?.cancel();
   }
 
-  getCursorStops(): HTMLElement[] {
+  getCursorStops(): Array<HTMLElement | AbortStop> {
     if (this.hidden && this.noAutoRender) return [];
-    if (!this.root) return [];
+
+    if (this.loading) {
+      return [new AbortStop()];
+    }
 
     return Array.from(
-      this.root.querySelectorAll<HTMLElement>(
+      this.root?.querySelectorAll<HTMLElement>(
         ':not(.contextControl) > .diff-row'
-      )
+      ) || []
     ).filter(tr => tr.querySelector('button'));
   }
 
@@ -488,7 +503,7 @@
   }
 
   toggleLeftDiff() {
-    this.toggleClass('no-left');
+    toggleClass(this, 'no-left');
   }
 
   _blameChanged(newValue?: BlameInfo[] | null) {
@@ -515,23 +530,18 @@
     return classes.join(' ');
   }
 
+  _handleDiffContextExpanded(e: CustomEvent<DiffContextExpandedEventDetail>) {
+    // Don't stop propagation. The host may listen for reporting or resizing.
+    this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
+  }
+
   _handleTap(e: CustomEvent) {
     const el = (dom(e) as EventApi).localTarget as Element;
 
-    if (el.classList.contains('showContext')) {
-      this.dispatchEvent(
-        new CustomEvent('diff-context-expanded', {
-          detail: {
-            numLines: e.detail.numLines,
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
-      this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
-    } else if (
-      el.classList.contains('lineNum') ||
-      el.classList.contains('lineNumButton')
+    if (
+      el.getAttribute('data-value') !== 'LOST' &&
+      (el.classList.contains('lineNum') ||
+        el.classList.contains('lineNumButton'))
     ) {
       this.addDraftAtLine(el);
     } else if (
@@ -547,11 +557,17 @@
   }
 
   _selectLine(el: Element) {
+    const lineNumber = Number(el.getAttribute('data-value'));
+    const side = el.classList.contains('left') ? Side.LEFT : Side.RIGHT;
+    this._dispatchSelectedLine(lineNumber, side);
+  }
+
+  _dispatchSelectedLine(number: LineNumber, side: Side) {
     this.dispatchEvent(
       new CustomEvent('line-selected', {
         detail: {
-          side: el.classList.contains('left') ? Side.LEFT : Side.RIGHT,
-          number: el.getAttribute('data-value'),
+          number,
+          side,
           path: this.path,
         },
         composed: true,
@@ -560,30 +576,20 @@
     );
   }
 
+  _movedLinkClicked(e: MovedLinkClickedEvent) {
+    this._dispatchSelectedLine(e.detail.lineNum, e.detail.side);
+  }
+
   addDraftAtLine(el: Element) {
     this._selectLine(el);
-    if (!this._isValidElForComment(el)) {
-      return;
-    }
 
     const lineNum = getLineNumber(el);
     if (lineNum === null) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: 'Invalid line number'},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Invalid line number');
       return;
     }
 
-    // TODO(TS): existing logic always pass undefined lineNum
-    // for file level comment, the drafts API will reject the
-    // request if file level draft contains the `line: 'FILE'` field
-    // probably should do this inside of the _createComment, this
-    // is just to keep existing behavior.
-    this._createComment(el, lineNum === FILE ? undefined : lineNum);
+    this._createComment(el, lineNum);
   }
 
   createRangeComment() {
@@ -599,7 +605,7 @@
   _createCommentForSelection(side: Side, range: CommentRange) {
     const lineNum = range.end_line;
     const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
-    if (lineEl && this._isValidElForComment(lineEl)) {
+    if (lineEl) {
       this._createComment(lineEl, lineNum, side, range);
     }
   }
@@ -610,86 +616,24 @@
     this._createCommentForSelection(side, range);
   }
 
-  _isValidElForComment(el: Element) {
-    if (!this.loggedIn) {
-      this.dispatchEvent(
-        new CustomEvent('show-auth-required', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      return false;
-    }
-    if (!this.patchRange) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: 'Cannot create comment. Patch range undefined.'},
-          composed: true,
-          bubbles: true,
-        })
-      );
-      return false;
-    }
-    const patchNum = el.classList.contains(Side.LEFT)
-      ? this.patchRange.basePatchNum
-      : this.patchRange.patchNum;
-
-    const isEdit = patchNumEquals(patchNum, EditPatchSetNum);
-    const isEditBase =
-      patchNumEquals(patchNum, ParentPatchSetNum) &&
-      patchNumEquals(this.patchRange.patchNum, EditPatchSetNum);
-
-    if (isEdit) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: 'You cannot comment on an edit.'},
-          composed: true,
-          bubbles: true,
-        })
-      );
-      return false;
-    }
-    if (isEditBase) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'You cannot comment on the base patchset of an edit.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
-      return false;
-    }
-    return true;
-  }
-
   _createComment(
     lineEl: Element,
-    lineNum?: LineNumber,
+    lineNum: LineNumber,
     side?: Side,
     range?: CommentRange
   ) {
     const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
-    if (!contentEl) throw Error('content el not found for line el');
-    side = side || this._getCommentSideByLineAndContent(lineEl, contentEl);
-    const patchForNewThreads = this._getPatchNumByLineAndContent(
-      lineEl,
-      contentEl
-    );
-    const isOnParent = this._getIsParentCommentByLineAndContent(
-      lineEl,
-      contentEl
-    );
+    if (!contentEl) throw new Error('content el not found for line el');
+    side = side ?? this._getCommentSideByLineAndContent(lineEl, contentEl);
+    assertIsDefined(this.path, 'path');
     this.dispatchEvent(
-      new CustomEvent('create-comment', {
+      new CustomEvent<CreateCommentEventDetail>('create-comment', {
         bubbles: true,
         composed: true,
         detail: {
-          lineNum,
+          path: this.path,
           side,
-          patchNum: patchForNewThreads,
-          isOnParent,
+          lineNum,
           range,
         },
       })
@@ -716,52 +660,11 @@
     return threadGroupEl;
   }
 
-  /**
-   * The value to be used for the patch number of new comments created at the
-   * given line and content elements.
-   *
-   * In two cases of creating a comment on the left side, the patch number to
-   * be used should actually be right side of the patch range:
-   * - When the patch range is against the parent comment of a normal change.
-   * Such comments declare themmselves to be on the left using side=PARENT.
-   * - If the patch range is against the indexed parent of a merge change.
-   * Such comments declare themselves to be on the given parent by
-   * specifying the parent index via parent=i.
-   */
-  _getPatchNumByLineAndContent(lineEl: Element, contentEl: Element) {
-    if (!this.patchRange) throw Error('patch range not set');
-    let patchNum = this.patchRange.patchNum;
-
-    if (
-      (lineEl.classList.contains(Side.LEFT) ||
-        contentEl.classList.contains('remove')) &&
-      this.patchRange.basePatchNum !== 'PARENT' &&
-      !isMergeParent(this.patchRange.basePatchNum)
-    ) {
-      patchNum = this.patchRange.basePatchNum;
-    }
-    return patchNum;
-  }
-
-  _getIsParentCommentByLineAndContent(lineEl: Element, contentEl: Element) {
-    if (!this.patchRange) throw Error('patch range not set');
-    return (
-      (lineEl.classList.contains(Side.LEFT) ||
-        contentEl.classList.contains('remove')) &&
-      (this.patchRange.basePatchNum === 'PARENT' ||
-        isMergeParent(this.patchRange.basePatchNum))
-    );
-  }
-
   _getCommentSideByLineAndContent(lineEl: Element, contentEl: Element): Side {
-    let side = Side.RIGHT;
-    if (
-      lineEl.classList.contains(Side.LEFT) ||
+    return lineEl.classList.contains(Side.LEFT) ||
       contentEl.classList.contains('remove')
-    ) {
-      side = Side.LEFT;
-    }
-    return side;
+      ? Side.LEFT
+      : Side.RIGHT;
   }
 
   _prefsObserver(newPrefs: DiffPreferencesInfo, oldPrefs: DiffPreferencesInfo) {
@@ -808,6 +711,10 @@
     this._prefsChanged(this.prefs);
   }
 
+  _useNewImageDiffUiObserver() {
+    this._prefsChanged(this.prefs);
+  }
+
   _prefsChanged(prefs?: DiffPreferencesInfo) {
     if (!prefs) return;
 
@@ -841,8 +748,21 @@
     }
   }
 
+  _renderPrefsChanged(renderPrefs?: RenderPreferences) {
+    if (!renderPrefs) return;
+    if (renderPrefs.hide_left_side) {
+      this.classList.add('no-left');
+    }
+    if (renderPrefs.disable_context_control_buttons) {
+      this.classList.add('disable-context-control-buttons');
+    }
+    if (renderPrefs.hide_line_length_indicator) {
+      this.classList.add('hide-line-length-indicator');
+    }
+  }
+
   _diffChanged(newValue?: DiffInfo) {
-    this._loading = true;
+    this._setLoading(true);
     this._cleanup();
     if (newValue) {
       this._diffLength = this.getDiffLength(newValue);
@@ -861,16 +781,14 @@
    * render once.
    */
   _debounceRenderDiffTable() {
-    this.debounce(RENDER_DIFF_TABLE_DEBOUNCE_NAME, () =>
+    this.renderDiffTableTask = debounce(this.renderDiffTableTask, () =>
       this._renderDiffTable()
     );
   }
 
   _renderDiffTable() {
     if (!this.prefs) {
-      this.dispatchEvent(
-        new CustomEvent('render', {bubbles: true, composed: true})
-      );
+      fireEvent(this, 'render');
       return;
     }
     if (
@@ -880,9 +798,7 @@
       this._safetyBypass === null
     ) {
       this._showWarning = true;
-      this.dispatchEvent(
-        new CustomEvent('render', {bubbles: true, composed: true})
-      );
+      fireEvent(this, 'render');
       return;
     }
 
@@ -890,19 +806,24 @@
 
     const keyLocations = this._computeKeyLocations();
     const bypassPrefs = this._getBypassPrefs(this.prefs);
-    this.$.diffBuilder.render(keyLocations, bypassPrefs).then(() => {
-      this.dispatchEvent(
-        new CustomEvent('render', {
-          bubbles: true,
-          composed: true,
-          detail: {contentRendered: true},
-        })
-      );
-    });
+    this.$.diffBuilder
+      .render(keyLocations, bypassPrefs, this.renderPrefs)
+      .then(() => {
+        this.dispatchEvent(
+          new CustomEvent('render', {
+            bubbles: true,
+            composed: true,
+            detail: {contentRendered: true},
+          })
+        );
+      });
   }
 
   _handleRenderContent() {
-    this._loading = false;
+    this.querySelectorAll('gr-ranged-comment-hint').forEach(element =>
+      element.remove()
+    );
+    this._setLoading(false);
     this._unobserveIncrementalNodes();
     this._incrementalNodeObserver = (dom(
       this
@@ -915,10 +836,12 @@
       // for each line from the start.
       let lastEl;
       for (const threadEl of addedThreadEls) {
-        const lineNumString = threadEl.getAttribute('line-num') || 'FILE';
+        const lineNum = getLine(threadEl);
         const commentSide = getSide(threadEl);
+        const range = getRange(threadEl);
+        if (!commentSide) continue;
         const lineEl = this.$.diffBuilder.getLineElByNumber(
-          lineNumString,
+          lineNum,
           commentSide
         );
         // When the line the comment refers to does not exist, log an error
@@ -928,17 +851,33 @@
           console.error(
             'thread attached to line ',
             commentSide,
-            lineNumString,
+            lineNum,
             ' which does not exist.'
           );
           continue;
         }
         const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
         if (!contentEl) continue;
+        if (lineNum === 'LOST' && !contentEl.hasChildNodes()) {
+          contentEl.appendChild(this._portedCommentsWithoutRangeMessage());
+        }
         const threadGroupEl = this._getOrCreateThreadGroup(
           contentEl,
           commentSide
         );
+
+        const slotAtt = threadEl.getAttribute('slot');
+        if (range && isLongCommentRange(range) && slotAtt) {
+          const longRangeCommentHint = document.createElement(
+            'gr-ranged-comment-hint'
+          );
+          longRangeCommentHint.range = range;
+          longRangeCommentHint.setAttribute('threadElRootId', threadEl.rootId);
+          longRangeCommentHint.setAttribute('slot', slotAtt);
+          this.insertBefore(longRangeCommentHint, threadEl);
+          this._redispatchHoverEvents(longRangeCommentHint, threadEl);
+        }
+
         // Create a slot for the thread and attach it to the thread group.
         // The Polyfill has some bugs and this only works if the slot is
         // attached to the group after the group is attached to the DOM.
@@ -946,7 +885,6 @@
         // that is okay because the first matching slot is used and the rest
         // are ignored.
         const slot = document.createElement('slot') as HTMLSlotElement;
-        const slotAtt = threadEl.getAttribute('slot');
         if (slotAtt) slot.name = slotAtt;
         threadGroupEl.appendChild(slot);
         lastEl = threadEl;
@@ -958,9 +896,27 @@
       if (lastEl && lastEl.replaceWith) {
         lastEl.replaceWith(lastEl);
       }
+
+      const removedThreadEls = info.removedNodes.filter(isThreadEl);
+      for (const threadEl of removedThreadEls) {
+        this.querySelector(
+          `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
+        )?.remove();
+      }
     });
   }
 
+  _portedCommentsWithoutRangeMessage() {
+    const div = document.createElement('div');
+    const icon = document.createElement('iron-icon');
+    icon.setAttribute('icon', 'gr-icons:info-outline');
+    div.appendChild(icon);
+    const span = document.createElement('span');
+    span.innerText = 'Original comment position not found in this patchset';
+    div.appendChild(span);
+    return div;
+  }
+
   _unobserveIncrementalNodes() {
     if (this._incrementalNodeObserver) {
       (dom(this) as PolymerDomWrapper).unobserveNodes(
@@ -1020,8 +976,12 @@
     this._debounceRenderDiffTable();
   }
 
-  _handleLimitedBypass() {
-    this._safetyBypass = LIMITED_CONTEXT;
+  _collapseContext() {
+    // Uses the default context amount if the preference is for the entire file.
+    this._safetyBypass =
+      this.prefs?.context && this.prefs.context >= 0
+        ? null
+        : createDefaultDiffPrefs().context;
     this._debounceRenderDiffTable();
   }
 
@@ -1033,8 +993,15 @@
     return errorMessage ? 'showError' : '';
   }
 
-  expandAllContext() {
-    this._handleFullBypass();
+  toggleAllContext() {
+    if (!this.prefs) {
+      return;
+    }
+    if (this._getBypassPrefs(this.prefs).context < 0) {
+      this._collapseContext();
+    } else {
+      this._handleFullBypass();
+    }
   }
 
   _computeNewlineWarning(warnLeft: boolean, warnRight: boolean) {
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 ef2110b..e60fee8 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
@@ -24,10 +24,23 @@
     :host(.no-left) .sideBySide .right:not([data-value]) + td {
       display: none;
     }
+    :host(.disable-context-control-buttons) {
+      --context-control-display: none;
+    }
+    :host(.disable-context-control-buttons) .section {
+      border-right: none;
+    }
+    :host(.hide-line-length-indicator) .full-width td.content .contentText {
+      background-image: none;
+    }
+
     :host {
       font-family: var(--monospace-font-family, ''), 'Roboto Mono';
       font-size: var(--font-size, var(--font-size-code, 12px));
-      line-height: var(--line-height-code, 1.334);
+      /* usually 16px = 12px + 4px */
+      line-height: calc(
+        var(--font-size, var(--font-size-code, 12px)) + var(--spacing-s, 4px)
+      );
     }
 
     .thread-group {
@@ -56,7 +69,7 @@
     .section {
       border-right: 1px solid var(--border-color);
     }
-    .section.contextControl.newStyle {
+    .section.contextControl {
       /*
        * Divider inside this section must not have border; we set borders on
        * the padding rows below.
@@ -64,9 +77,9 @@
       border-right-width: 0;
     }
     /*
-     * Padding rows behind new style context controls. The diff is styled to be
-     * cut into two halves by the negative space of the divider on which the
-     * context control buttons are anchored.
+     * Padding rows behind context controls. The diff is styled to be cut into
+     * two halves by the negative space of the divider on which the context
+     * control buttons are anchored.
      */
     .contextBackground {
       border-right: 1px solid var(--border-color);
@@ -95,6 +108,12 @@
     .lineNumButton:focus {
       outline: none;
     }
+    gr-image-viewer {
+      width: 100%;
+      height: 100%;
+      max-width: var(--image-viewer-max-width, 95vw);
+      max-height: var(--image-viewer-max-height, 90vh);
+    }
     .image-diff .gr-diff {
       text-align: center;
     }
@@ -146,7 +165,7 @@
       width: 100%;
     }
     .full-width .contentText {
-      white-space: pre-wrap;
+      white-space: break-spaces;
       word-wrap: break-word;
     }
     .lineNumButton,
@@ -212,17 +231,45 @@
 
     /* dueToMove */
     .dueToMove .content.add .contentText,
-    .dueToMove .moveControls.movedIn .moveDescription,
+    .dueToMove .moveControls.movedIn .moveLabel,
     .delta.total.dueToMove .content.add .contentText {
-      background-color: var(--light-moved-add-highlight-color);
+      background-color: var(--diff-moved-in-background);
     }
+
     .dueToMove .content.remove .contentText,
-    .dueToMove .moveControls.movedOut .moveDescription,
+    .dueToMove .moveControls.movedOut .moveLabel,
     .delta.total.dueToMove .content.remove .contentText {
-      background-color: var(--light-remove-add-highlight-color);
+      background-color: var(--diff-moved-out-background);
     }
-    .moveControls {
-      text-align: right;
+
+    .delta.dueToMove .movedIn .moveDescription {
+      color: var(--diff-moved-in-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;
+    }
+
+    .moveDescription iron-icon {
+      color: inherit;
+      margin-right: var(--spacing-s);
+      height: var(--line-height-small, 16px);
+      width: var(--line-height-small, 16px);
+    }
+
+    .moveDescription a {
+      color: inherit;
     }
 
     /* ignoredWhitespaceOnly */
@@ -242,27 +289,11 @@
 
     /* Context controls */
     .contextControl {
-      background-color: var(--diff-context-control-background-color);
-      border: 1px solid var(--diff-context-control-border-color);
-      color: var(--diff-context-control-color);
-      --divider-height: var(--spacing-s);
-      --divider-border: 1px;
-    }
-    .contextControl.newStyle {
+      display: var(--context-control-display, table-row-group);
       background-color: transparent;
       border: none;
-      /* Change to --diff-context-control-color once only new style exists. */
-      --diff-context-control-color: var(--default-button-text-color);
-    }
-    .contextControl:not(.newStyle) gr-button {
-      display: inline-block;
-      text-decoration: none;
-      vertical-align: top;
-      line-height: var(--line-height-mono, 18px);
-      --gr-button: {
-        color: var(--diff-context-control-color);
-        padding: var(--spacing-xxs) var(--spacing-l);
-      }
+      --divider-height: var(--spacing-s);
+      --divider-border: 1px;
     }
     .contextControl gr-button iron-icon {
       /* should match line-height of gr-button */
@@ -274,8 +305,8 @@
     }
 
     /*
-     * Padding rows behind new style context controls. Styled as a continuation
-     * of the line gutters and code area.
+     * Padding rows behind context controls. Styled as a continuation of the
+     * line gutters and code area.
      */
     .contextBackground > .contextLineNum {
       background-color: var(--diff-blank-background-color);
@@ -284,81 +315,128 @@
       background-color: var(--view-background-color);
     }
     .contextBackground {
-      /* 
-       * One line of background behind the context expanders which they can 
+      /*
+       * One line of background behind the context expanders which they can
        * render on top of, plus some padding.
        */
       height: calc(var(--line-height-normal) + var(--spacing-s));
     }
 
-    .contextDivider {
-      height: var(--divider-height);
-      /* Create a positioning context. */
-      transform: translateX(0px);
+    .dividerCell {
+      vertical-align: top;
     }
-    .contextDivider.collapsed {
-      /* Hide divider gap, but still show child elements (expansion buttons). */
+    .dividerRow.showBoth .dividerCell {
+      height: var(--divider-height);
+    }
+    .dividerRow.showAboveOnly .dividerCell,
+    .dividerRow.showBelowOnly .dividerCell {
       height: 0;
     }
-    .dividerCell {
-      width: 100%;
-      height: 100%;
+
+    .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;
-      position: absolute;
-      top: 0;
-      left: 0;
+    }
+    .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);
-      /* All position is relative to container, so ignore sibling buttons. */
-      position: absolute;
-    }
-    .contextControlButton:first-child {
-      /* First button needs to claim width to display without text wrapping. */
-      position: relative;
     }
     .centeredButton {
-      /* Center over divider. */
-      top: 50%;
-      transform: translateY(-50%);
       --gr-button: {
         color: var(--diff-context-control-color);
-        border: solid var(--border-color);
-        border-width: 1px;
-        border-radius: var(--border-radius);
+        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);
-      position: relative;
     }
     .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 {
-      /* Display over preceding content / background placeholder. */
-      transform: translateY(-100%);
       --gr-button: {
         color: var(--diff-context-control-color);
-        border: solid var(--border-color);
-        border-width: 1px 1px 0 1px;
-        border-radius: var(--border-radius) var(--border-radius) 0 0;
+        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 {
-      /* Display over following content / background placeholder. */
-      top: calc(100% + var(--divider-border));
       --gr-button: {
         color: var(--diff-context-control-color);
-        border: solid var(--border-color);
-        border-width: 0 1px 1px 1px;
-        border-radius: 0 0 var(--border-radius) var(--border-radius);
+        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);
       }
     }
@@ -435,6 +513,19 @@
     .target-row td.blame {
       background: var(--diff-selection-background-color);
     }
+    td.lost div {
+      background-color: var(--info-background);
+      padding: var(--spacing-s) 0 0 0;
+    }
+    td.lost div:first-of-type {
+      font-family: var(--font-family, 'Roboto');
+      font-size: var(--font-size-normal, 14px);
+      line-height: var(--line-height-normal);
+    }
+    td.lost iron-icon {
+      padding: 0 var(--spacing-s) 0 var(--spacing-m);
+      color: var(--blue-700);
+    }
     col.blame {
       display: none;
     }
@@ -466,8 +557,7 @@
     }
     /** Support the line length indicator **/
     .full-width td.content .contentText {
-      /* Base 64 encoded 1x1px of #ddd */
-      background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mO8+x8AAr8B3gzOjaQAAAAASUVORK5CYII=');
+      background-image: var(--line-length-indicator);
       background-position: var(--line-limit) 0;
       background-repeat: repeat-y;
     }
@@ -542,9 +632,9 @@
 
     /** Make comments selectable when selected */
     .selected-left.selected-comment
-      ::slotted(gr-comment-thread[comment-side='left']),
+      ::slotted(gr-comment-thread[diff-side='left']),
     .selected-right.selected-comment
-      ::slotted(gr-comment-thread[comment-side='right']) {
+      ::slotted(gr-comment-thread[diff-side='right']) {
       -webkit-user-select: text;
       -moz-user-select: text;
       -ms-user-select: text;
@@ -571,7 +661,8 @@
   </div>
   <div
     class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
-    on-tap="_handleTap"
+    on-click="_handleTap"
+    on-diff-context-expanded="_handleDiffContextExpanded"
   >
     <gr-diff-selection diff="[[diff]]">
       <gr-diff-highlight
@@ -592,7 +683,7 @@
           base-image="[[baseImage]]"
           layers="[[layers]]"
           revision-image="[[revisionImage]]"
-          use-new-context-controls="[[useNewContextControls]]"
+          use-new-image-diff-ui="[[useNewImageDiffUi]]"
         >
           <table
             id="diffTable"
@@ -625,7 +716,7 @@
       Prevented render because "Whole file" is enabled and this diff is very
       large (about [[_diffLength]] lines).
     </p>
-    <gr-button on-click="_handleLimitedBypass">
+    <gr-button on-click="_collapseContext">
       Render with limited context
     </gr-button>
     <gr-button on-click="_handleFullBypass">
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
index 36b3b8f..86946a6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
@@ -16,16 +16,14 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import './gr-diff.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
 import {getComputedStyleValue} from '../../../utils/dom-util.js';
 import {_setHiddenScroll} from '../../../scripts/hiddenscroll.js';
 import {runA11yAudit} from '../../../test/a11y-test-utils.js';
 import '@polymer/paper-button/paper-button.js';
-import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-diff');
 
@@ -54,15 +52,17 @@
       sinon.stub(element.$.highlights, 'handleSelectionChange');
     });
 
-    test('enabled if logged in', () => {
+    test('enabled if logged in', async () => {
       element.loggedIn = true;
       emulateSelection();
+      await flush();
       assert.isTrue(element.$.highlights.handleSelectionChange.called);
     });
 
-    test('ignored if logged out', () => {
+    test('ignored if logged out', async () => {
       element.loggedIn = false;
       emulateSelection();
+      await flush();
       assert.isFalse(element.$.highlights.handleSelectionChange.called);
     });
   });
@@ -88,97 +88,10 @@
     assert.isNotOk(getComputedStyleValue('--line-limit', element));
   });
 
-  suite('_get{PatchNum|IsParentComment}ByLineAndContent', () => {
-    let lineEl;
-    let contentEl;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      lineEl = document.createElement('td');
-      contentEl = document.createElement('span');
-    });
-
-    suite('_getPatchNumByLineAndContent', () => {
-      test('right side', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        lineEl.classList.add('right');
-        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-            4);
-      });
-
-      test('left side parent by linenum', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        lineEl.classList.add('left');
-        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-            4);
-      });
-
-      test('left side parent by content', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        contentEl.classList.add('remove');
-        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-            4);
-      });
-
-      test('left side merge parent', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: -2};
-        contentEl.classList.add('remove');
-        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-            4);
-      });
-
-      test('left side non parent', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 3};
-        contentEl.classList.add('remove');
-        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-            3);
-      });
-    });
-
-    suite('_getIsParentCommentByLineAndContent', () => {
-      test('right side', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        lineEl.classList.add('right');
-        assert.isFalse(
-            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-      });
-
-      test('left side parent by linenum', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        lineEl.classList.add('left');
-        assert.isTrue(
-            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-      });
-
-      test('left side parent by content', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        contentEl.classList.add('remove');
-        assert.isTrue(
-            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-      });
-
-      test('left side merge parent', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: -2};
-        contentEl.classList.add('remove');
-        assert.isTrue(
-            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-      });
-
-      test('left side non parent', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 3};
-        contentEl.classList.add('remove');
-        assert.isFalse(
-            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-      });
-    });
-  });
-
   suite('not logged in', () => {
     setup(() => {
       const getLoggedInPromise = Promise.resolve(false);
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return getLoggedInPromise; },
-      });
+      stubRestApi('getLoggedIn').returns(getLoggedInPromise);
       element = basicFixture.instantiate();
       return getLoggedInPromise;
     });
@@ -190,14 +103,6 @@
       assert.isFalse(element.classList.contains('no-left'));
     });
 
-    test('addDraftAtLine', () => {
-      sinon.stub(element, '_selectLine');
-      const loggedInErrorSpy = sinon.spy();
-      element.addEventListener('show-auth-required', loggedInErrorSpy);
-      element.addDraftAtLine();
-      assert.isTrue(loggedInErrorSpy.called);
-    });
-
     test('view does not start with displayLine classList', () => {
       assert.isFalse(
           element.shadowRoot
@@ -258,12 +163,10 @@
         element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
         element.isImageDiff = true;
         element.prefs = {
-          auto_hide_diff_table_header: true,
           context: 10,
           cursor_blink_rate: 0,
           font_size: 12,
           ignore_whitespace: 'IGNORE_NONE',
-          intraline_difference: true,
           line_length: 100,
           line_wrapping: false,
           show_line_endings: true,
@@ -305,7 +208,7 @@
           leftImage.addEventListener('load', () => {
             assert.isOk(leftImage);
             assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile1.body);
+                'data:image/bmp;base64,' + mockFile1.body);
             assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
             leftLoaded = true;
             if (rightLoaded) {
@@ -317,7 +220,7 @@
           rightImage.addEventListener('load', () => {
             assert.isOk(rightImage);
             assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile2.body);
+                'data:image/bmp;base64,' + mockFile2.body);
             assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
 
             rightLoaded = true;
@@ -399,7 +302,7 @@
           leftImage.addEventListener('load', () => {
             assert.isOk(leftImage);
             assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile1.body);
+                'data:image/bmp;base64,' + mockFile1.body);
             assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
             leftLoaded = true;
             if (rightLoaded) {
@@ -411,7 +314,7 @@
           rightImage.addEventListener('load', () => {
             assert.isOk(rightImage);
             assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile2.body);
+                'data:image/bmp;base64,' + mockFile2.body);
             assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
 
             rightLoaded = true;
@@ -559,7 +462,7 @@
       const el = document.createElement('div');
       el.className = 'showContext';
       el.addEventListener('click', e => {
-        element._handleTap(e);
+        element._handleDiffContextExpanded(e);
         assert.isTrue(showContextStub.called);
         done();
       });
@@ -585,7 +488,7 @@
     });
 
     suite('getCursorStops', () => {
-      const setupDiff = function() {
+      function setupDiff() {
         element.diff = getMockDiffResponse();
         element.prefs = {
           context: 10,
@@ -594,19 +497,19 @@
           line_length: 100,
           cursor_blink_rate: 0,
           line_wrapping: false,
-          intraline_difference: true,
+
           show_line_endings: true,
           show_tabs: true,
           show_whitespace_errors: true,
           syntax_highlighting: true,
-          auto_hide_diff_table_header: true,
           theme: 'DEFAULT',
           ignore_whitespace: 'IGNORE_NONE',
         };
 
         element._renderDiffTable();
+        element._setLoading(false);
         flush();
-      };
+      }
 
       test('getCursorStops returns [] when hidden and noAutoRender', () => {
         element.noAutoRender = true;
@@ -654,29 +557,100 @@
           .calledWithExactly(fakeLineEl, 42));
     });
 
-    test('addDraftAtLine on an edit', () => {
-      element.patchRange.basePatchNum = SPECIAL_PATCH_SET_NUM.EDIT;
-      sinon.stub(element, '_selectLine');
-      sinon.stub(element, '_createComment');
-      const alertSpy = sinon.spy();
-      element.addEventListener('show-alert', alertSpy);
-      element.addDraftAtLine(fakeLineEl);
-      assert.isTrue(alertSpy.called);
-      assert.isFalse(element._createComment.called);
+    test('adds long range comment hint', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 12,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', 1);
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const content = [{
+        a: [],
+        b: [],
+      }, {
+        ab: Array(13).fill('text'),
+      }];
+      setupSampleDiff({content});
+
+      element.appendChild(threadEl);
+      await flush();
+
+      assert.deepEqual(
+          element.querySelector('gr-ranged-comment-hint').range, range);
     });
 
-    test('addDraftAtLine on an edit base', () => {
-      element.patchRange.patchNum = SPECIAL_PATCH_SET_NUM.EDIT;
-      element.patchRange.basePatchNum = SPECIAL_PATCH_SET_NUM.PARENT;
-      sinon.stub(element, '_selectLine');
-      sinon.stub(element, '_createComment');
-      const alertSpy = sinon.spy();
-      element.addEventListener('show-alert', alertSpy);
-      element.addDraftAtLine(fakeLineEl);
-      assert.isTrue(alertSpy.called);
-      assert.isFalse(element._createComment.called);
+    test('no duplicate range hint for same thread', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 12,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', 1);
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const firstHint = document.createElement('gr-ranged-comment-hint');
+      firstHint.range = range;
+      firstHint.setAttribute('threadElRootId', threadEl.rootId);
+      firstHint.setAttribute('slot', 'right-1');
+      const content = [{
+        a: [],
+        b: [],
+      }, {
+        ab: Array(13).fill('text'),
+      }];
+      setupSampleDiff({content});
+
+      element.appendChild(firstHint);
+      await flush();
+      element._handleRenderContent();
+      await flush();
+      element.appendChild(threadEl);
+      await flush();
+
+      assert.equal(
+          element.querySelectorAll('gr-ranged-comment-hint').length, 1);
     });
 
+    test('removes long range comment hint when comment is discarded',
+        async () => {
+          const range = {
+            start_line: 1,
+            end_line: 7,
+            start_character: 0,
+            end_character: 0,
+          };
+          const threadEl = document.createElement('div');
+          threadEl.className = 'comment-thread';
+          threadEl.setAttribute('diff-side', 'right');
+          threadEl.setAttribute('line-num', 1);
+          threadEl.setAttribute('range', JSON.stringify(range));
+          threadEl.setAttribute('slot', 'right-1');
+          const content = [{
+            a: [],
+            b: [],
+          }, {
+            ab: Array(8).fill('text'),
+          }];
+          setupSampleDiff({content});
+          element.appendChild(threadEl);
+          await flush();
+
+          threadEl.remove();
+          await flush();
+
+          assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
+        });
+
     suite('change in preferences', () => {
       setup(() => {
         element.diff = {
@@ -688,14 +662,14 @@
           change_type: 'MODIFIED',
           content: [{skip: 66}],
         };
-        element.flushDebouncer('renderDiffTable');
+        element.renderDiffTableTask.flush();
       });
 
       test('change in preferences re-renders diff', () => {
         sinon.stub(element, '_renderDiffTable');
         element.prefs = {
           ...MINIMAL_PREFS, time_format: 'HHMM_12'};
-        element.flushDebouncer('renderDiffTable');
+        element.renderDiffTableTask.flush();
         assert.isTrue(element._renderDiffTable.called);
       });
 
@@ -704,14 +678,14 @@
         const newPrefs1 = {...MINIMAL_PREFS,
           line_wrapping: true};
         element.prefs = newPrefs1;
-        element.flushDebouncer('renderDiffTable');
+        element.renderDiffTableTask.flush();
         assert.isTrue(element._renderDiffTable.called);
         stub.reset();
 
         const newPrefs2 = {...newPrefs1};
         delete newPrefs2.line_wrapping;
         element.prefs = newPrefs2;
-        element.flushDebouncer('renderDiffTable');
+        element.renderDiffTableTask.flush();
         assert.isTrue(element._renderDiffTable.called);
       });
 
@@ -721,7 +695,7 @@
         element.noRenderOnPrefsChange = true;
         element.prefs = {
           ...MINIMAL_PREFS, time_format: 'HHMM_12'};
-        element.flushDebouncer('renderDiffTable');
+        element.renderDiffTableTask.flush();
         assert.isFalse(element._renderDiffTable.called);
       });
     });
@@ -822,6 +796,43 @@
       element.addEventListener('render', rendered);
       element._renderDiffTable();
     });
+
+    test('toggles expand context using bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 3};
+
+      element.toggleAllContext();
+      element._renderDiffTable();
+      await flush();
+
+      assert.equal(element.prefs.context, 3);
+      assert.equal(element._safetyBypass, -1);
+      assert.equal(renderStub.firstCall.args[1].context, -1);
+    });
+
+    test('toggles collapse context from bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 3};
+      element._safetyBypass = -1;
+
+      element.toggleAllContext();
+      element._renderDiffTable();
+      await flush();
+
+      assert.equal(element.prefs.context, 3);
+      assert.isNull(element._safetyBypass);
+      assert.equal(renderStub.firstCall.args[1].context, 3);
+    });
+
+    test('toggles collapse context from pref using default', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+
+      element.toggleAllContext();
+      element._renderDiffTable();
+      await flush();
+
+      assert.equal(element.prefs.context, -1);
+      assert.equal(element._safetyBypass, 10);
+      assert.equal(renderStub.firstCall.args[1].context, 10);
+    });
   });
 
   suite('blame', () => {
@@ -949,7 +960,7 @@
     test('line comments are key locations', () => {
       const threadEl = document.createElement('div');
       threadEl.className = 'comment-thread';
-      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('diff-side', 'right');
       threadEl.setAttribute('line-num', 3);
       element.appendChild(threadEl);
       flush();
@@ -965,7 +976,7 @@
     test('file comments are key locations', () => {
       const threadEl = document.createElement('div');
       threadEl.className = 'comment-thread';
-      threadEl.setAttribute('comment-side', 'left');
+      threadEl.setAttribute('diff-side', 'left');
       element.appendChild(threadEl);
       flush();
 
@@ -984,11 +995,10 @@
     element = basicFixture.instantiate();
     element.prefs = {
       ignore_whitespace: ignore_whitespace || 'IGNORE_ALL',
-      auto_hide_diff_table_header: true,
       context: 10,
       cursor_blink_rate: 0,
       font_size: 12,
-      intraline_difference: true,
+
       line_length: 100,
       line_wrapping: false,
       show_line_endings: true,
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index 2a9fe54..9bd2904 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
@@ -18,11 +18,9 @@
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
 import '../../shared/gr-select/gr-select';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-patch-range-select_html';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {pluralize} from '../../../utils/string-util';
 import {appContext} from '../../../services/app-context';
 import {
   computeLatestPatchNum,
@@ -30,14 +28,15 @@
   getParentIndex,
   getRevisionByPatchNum,
   isMergeParent,
-  patchNumEquals,
   sortRevisions,
   PatchSet,
+  convertToPatchSetNum,
 } from '../../../utils/patch-set-util';
 import {customElement, property, observe} from '@polymer/decorators';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {
+  BasePatchSetNum,
   ParentPatchSetNum,
   PatchSetNum,
   RevisionInfo,
@@ -58,7 +57,7 @@
 
 export interface PatchRangeChangeDetail {
   patchNum?: PatchSetNum;
-  basePatchNum?: PatchSetNum;
+  basePatchNum?: BasePatchSetNum;
 }
 
 export type PatchRangeChangeEvent = CustomEvent<PatchRangeChangeDetail>;
@@ -81,12 +80,9 @@
  *
  * @property {string} patchNum
  * @property {string} basePatchNum
- * @extends PolymerElement
  */
 @customElement('gr-patch-range-select')
-export class GrPatchRangeSelect extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrPatchRangeSelect extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -123,7 +119,7 @@
   patchNum?: PatchSetNum;
 
   @property({type: String})
-  basePatchNum?: PatchSetNum;
+  basePatchNum?: BasePatchSetNum;
 
   @property({type: Object})
   revisions?: RevisionInfo[];
@@ -222,7 +218,7 @@
 
   _computePatchDropdownContent(
     availablePatches?: PatchSet[],
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     _sortedRevisions?: RevisionInfo[],
     changeComments?: ChangeComments
   ): DropdownItem[] | undefined {
@@ -346,7 +342,7 @@
     patchNum: PatchSetNum,
     sortedRevisions: RevisionInfo[]
   ): boolean {
-    if (patchNumEquals(basePatchNum, ParentPatchSetNum)) {
+    if (basePatchNum === ParentPatchSetNum) {
       return false;
     }
 
@@ -367,6 +363,7 @@
     );
   }
 
+  // TODO(dhruvsri): have ported comments contribute to this count
   _computePatchSetCommentsString(
     changeComments: ChangeComments,
     patchNum: PatchSetNum
@@ -375,19 +372,20 @@
       return;
     }
 
-    const commentThreadCount = changeComments.computeCommentThreadCount({
-      patchNum,
-    });
-    const commentThreadString = GrCountStringFormatter.computePluralString(
-      commentThreadCount,
-      'comment'
+    const commentThreadCount = changeComments.computeCommentThreadCount(
+      {
+        patchNum,
+      },
+      true
     );
+    const commentThreadString = pluralize(commentThreadCount, 'comment');
 
-    const unresolvedCount = changeComments.computeUnresolvedNum({patchNum});
-    const unresolvedString = GrCountStringFormatter.computeString(
-      unresolvedCount,
-      'unresolved'
+    const unresolvedCount = changeComments.computeUnresolvedNum(
+      {patchNum},
+      true
     );
+    const unresolvedString =
+      unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
 
     if (!commentThreadString.length && !unresolvedString.length) {
       return '';
@@ -431,7 +429,7 @@
       basePatchNum: this.basePatchNum,
     };
     const target = (dom(e) as EventApi).localTarget;
-    const patchSetValue = e.detail.value as PatchSetNum;
+    const patchSetValue = convertToPatchSetNum(e.detail.value)!;
     const latestPatchNum = computeLatestPatchNum(this.availablePatches);
     if (target === this.$.patchNumDropdown) {
       if (detail.patchNum === e.detail.value) return;
@@ -445,7 +443,7 @@
       });
       detail.patchNum = patchSetValue;
     } else {
-      if (patchNumEquals(detail.basePatchNum, patchSetValue)) return;
+      if (detail.basePatchNum === patchSetValue) return;
       this.reporting.reportInteraction('left-patchset-changed', {
         previous: detail.basePatchNum,
         current: e.detail.value,
@@ -453,7 +451,7 @@
           patchNum: patchSetValue,
         }),
       });
-      detail.basePatchNum = patchSetValue;
+      detail.basePatchNum = patchSetValue as BasePatchSetNum;
     }
 
     this.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
index 52465b3..5ab8449 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
@@ -35,7 +35,6 @@
         text-transform: none;
         font-family: var(--font-family);
       }
-      --trigger-hover-color: rgba(0, 0, 0, 0.6);
     }
     @media screen and (max-width: 50em) {
       .filesWeblinks {
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
index 15841d9..a7719fe 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
@@ -24,8 +24,10 @@
 import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
 import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
+import {stubRestApi} from '../../../test/test-utils.js';
+import {EditPatchSetNum} from '../../../types/common.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
 
 const commentApiMockElement = createCommentApiMockWithTemplateElement(
     'gr-patch-range-select-comment-api-mock', html`
@@ -50,11 +52,9 @@
   }
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getDiffComments() { return Promise.resolve({}); },
-      getDiffRobotComments() { return Promise.resolve({}); },
-      getDiffDrafts() { return Promise.resolve({}); },
-    });
+    stubRestApi('getDiffComments').returns(Promise.resolve({}));
+    stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+    stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
 
     // Element must be wrapped in an element with direct access to the
     // comment API.
@@ -73,7 +73,7 @@
     };
     const sortedRevisions = [
       {_number: 3},
-      {_number: SPECIAL_PATCH_SET_NUM.EDIT, basePatchNum: 2},
+      {_number: EditPatchSetNum, basePatchNum: 2},
       {_number: 2},
       {_number: 1},
     ];
@@ -87,7 +87,7 @@
     }
     assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum));
 
-    patchRange.basePatchNum = SPECIAL_PATCH_SET_NUM.EDIT;
+    patchRange.basePatchNum = EditPatchSetNum;
     assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum,
         sortedRevisions));
     assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '1',
@@ -97,7 +97,7 @@
     assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum, '3',
         sortedRevisions));
     assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum,
-        SPECIAL_PATCH_SET_NUM.EDIT, sortedRevisions));
+        EditPatchSetNum, sortedRevisions));
   });
 
   test('_computeBaseDropdownContent', () => {
@@ -121,7 +121,7 @@
     const patchNum = 1;
     const sortedRevisions = [
       {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
-      {_number: SPECIAL_PATCH_SET_NUM.EDIT, basePatchNum: 2},
+      {_number: EditPatchSetNum, basePatchNum: 2},
       {_number: 2, description: 'description'},
       {_number: 1},
     ];
@@ -285,7 +285,7 @@
     const basePatchNum = 1;
     const sortedRevisions = [
       {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
-      {_number: SPECIAL_PATCH_SET_NUM.EDIT, basePatchNum: 2},
+      {_number: EditPatchSetNum, basePatchNum: 2},
       {_number: 2, description: 'description'},
       {_number: 1},
     ];
@@ -364,21 +364,31 @@
         unresolved: true,
         updated: '2017-10-11 20:48:40.000000000',
       }],
-      bar: [{
-        id: '27dcee4d_f7b77cfa',
-        message: 'test',
-        patch_set: 1,
-        updated: '2017-10-12 20:48:40.000000000',
-      },
-      {
-        id: '27dcee4d_f7b77cfa',
-        message: 'test',
-        patch_set: 1,
-        updated: '2017-10-13 20:48:40.000000000',
-      }],
+      bar: [
+        {
+          id: '27dcee4d_f7b77cfa',
+          message: 'test',
+          patch_set: 1,
+          updated: '2017-10-12 20:48:40.000000000',
+        },
+        {
+          id: '27dcee4d_f7b77cfa',
+          message: 'test',
+          patch_set: 1,
+          updated: '2017-10-13 20:48:40.000000000',
+        },
+      ],
       abc: [],
+      // Patchset level comment does not contribute to the count
+      [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
+        id: '27dcee4d_f7b77cfa',
+        message: 'test',
+        patch_set: 1,
+        unresolved: true,
+        updated: '2017-10-11 20:48:40.000000000',
+      }],
     };
-    element.changeComments = new ChangeComments(comments, {}, {}, 123);
+    element.changeComments = new ChangeComments(comments);
 
     assert.equal(element._computePatchSetCommentsString(
         element.changeComments, 1), ' (3 comments, 1 unresolved)');
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
similarity index 60%
rename from polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.ts
rename to polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
index b3a40c5..3d35c26 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
+ * Copyright (C) 2020 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,27 +14,29 @@
  * 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-plugin-repo-command_html';
+
 import {customElement, property} from '@polymer/decorators';
+import {CommentRange} from '../../../types/common';
+import {htmlTemplate} from './gr-ranged-comment-hint_html';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-plugin-repo-command': GrPluginRepoCommand;
-  }
-}
-@customElement('gr-plugin-repo-command')
-export class GrPluginRepoCommand extends PolymerElement {
-  @property({type: String})
-  title = '';
-
+@customElement('gr-ranged-comment-hint')
+export class GrRangedCommentHint extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
 
-  _handleClick() {
-    this.dispatchEvent(
-      new CustomEvent('command-tap', {composed: true, bubbles: true})
-    );
+  @property({type: Object})
+  range?: CommentRange;
+
+  _computeRangeLabel(range?: CommentRange): string {
+    if (!range) return '';
+    return `Long comment range ${range.start_line} - ${range.end_line}`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-ranged-comment-hint': GrRangedCommentHint;
   }
 }
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
new file mode 100644
index 0000000..670bfd2
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_html.ts
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {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
new file mode 100644
index 0000000..932c367
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import {CommentRange} from '../../../types/common';
+import {GrRangedCommentHint} from './gr-ranged-comment-hint';
+
+suite('gr-ranged-comment-hint tests', () => {
+  let element: GrRangedCommentHint;
+
+  setup(() => {
+    element = fixtureFromElement('gr-ranged-comment-hint').instantiate();
+  });
+
+  test('shows line range', async () => {
+    element.range = {
+      start_line: 2,
+      start_character: 1,
+      end_line: 5,
+      end_character: 3,
+    } as CommentRange;
+    await flush();
+    const textDiv = element.root!.querySelector<HTMLDivElement>('.row');
+    assert.equal(textDiv!.innerText.trim(), 'Long comment range 2 - 5');
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index bb3733f..11712dc 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -15,8 +15,6 @@
  * limitations under the License.
  */
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-ranged-comment-layer_html';
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
@@ -29,6 +27,7 @@
 } from '@polymer/polymer/interfaces';
 import {CommentRange} from '../../../types/common';
 import {DiffLayer, DiffLayerListener} from '../../../types/types';
+import {isLongCommentRange} from '../gr-diff/gr-diff-utils';
 
 /**
  * Enhanced CommentRange by UI state. Interface for incoming ranges set from the
@@ -49,6 +48,7 @@
  */
 interface CommentRangeLineLayer {
   hovering: boolean;
+  longRange: boolean;
   rootId: string;
   start: number;
   end: number;
@@ -65,13 +65,12 @@
 // Polymer 1 adds # before array's key, while Polymer 2 doesn't
 const HOVER_PATH_PATTERN = /^(commentRanges\.#?\d+)\.hovering$/;
 
-const RANGE_HIGHLIGHT = 'style-scope gr-diff range';
-const HOVER_HIGHLIGHT = 'style-scope gr-diff rangeHighlight';
+const RANGE_BASE_ONLY = 'style-scope gr-diff range';
+const RANGE_HIGHLIGHT = 'style-scope gr-diff range rangeHighlight';
+const HOVER_HIGHLIGHT = 'style-scope gr-diff range rangeHoverHighlight';
 
 @customElement('gr-ranged-comment-layer')
-export class GrRangedCommentLayer
-  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
-  implements DiffLayer {
+export class GrRangedCommentLayer extends PolymerElement implements DiffLayer {
   static get template() {
     return htmlTemplate;
   }
@@ -125,8 +124,11 @@
         el,
         range.start,
         range.end - range.start,
-        (range.hovering ? HOVER_HIGHLIGHT : RANGE_HIGHLIGHT) +
-          ` ${strToClassName(range.rootId)}`
+        (range.hovering
+          ? HOVER_HIGHLIGHT
+          : range.longRange
+          ? RANGE_BASE_ONLY
+          : RANGE_HIGHLIGHT) + ` ${strToClassName(range.rootId)}`
       );
     }
   }
@@ -169,12 +171,13 @@
       const value = record.value as CommentRangeLayer[];
       this._rangesMap = {left: {}, right: {}};
       for (const {side, range, rootId, hovering} of value) {
+        const longRange = isLongCommentRange(range);
         this._updateRangesMap({
           side,
           range,
           hovering,
           operation: (forLine, start, end, hovering) => {
-            forLine.push({start, end, hovering, rootId});
+            forLine.push({start, end, hovering, rootId, longRange});
           },
         });
       }
@@ -228,12 +231,13 @@
           indexSplice.index + indexSplice.addedCount
         );
         for (const {side, range, hovering, rootId} of added) {
+          const longRange = isLongCommentRange(range);
           this._updateRangesMap({
             side,
             range,
             hovering,
             operation: (forLine, start, end, hovering) => {
-              forLine.push({start, end, hovering, rootId});
+              forLine.push({start, end, hovering, rootId, longRange});
             },
           });
         }
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
index 441d585..8279ab1 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
@@ -68,6 +68,15 @@
         },
         rootId: 'd',
       },
+      {
+        side: 'right',
+        range: {
+          end_character: 1,
+          end_line: 71,
+          start_character: 1,
+          start_line: 60,
+        },
+      },
     ];
 
     element = basicFixture.instantiate();
@@ -110,7 +119,10 @@
       assert.equal(lastCall.args[0], el);
       assert.equal(lastCall.args[1], expectedStart);
       assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(lastCall.args[3], 'style-scope gr-diff range generated_a');
+      assert.equal(
+          lastCall.args[3],
+          'style-scope gr-diff range rangeHighlight generated_a'
+      );
     });
 
     test('type=Remove has-comment hovering', () => {
@@ -129,7 +141,8 @@
       assert.equal(lastCall.args[1], expectedStart);
       assert.equal(lastCall.args[2], expectedLength);
       assert.equal(
-          lastCall.args[3], 'style-scope gr-diff rangeHighlight generated_a'
+          lastCall.args[3],
+          'style-scope gr-diff range rangeHoverHighlight generated_a'
       );
     });
 
@@ -147,7 +160,10 @@
       assert.equal(lastCall.args[0], el);
       assert.equal(lastCall.args[1], expectedStart);
       assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(lastCall.args[3], 'style-scope gr-diff range generated_a');
+      assert.equal(
+          lastCall.args[3],
+          'style-scope gr-diff range rangeHighlight generated_a'
+      );
     });
 
     test('type=Both has-comment off side', () => {
@@ -175,7 +191,24 @@
       assert.equal(lastCall.args[0], el);
       assert.equal(lastCall.args[1], expectedStart);
       assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(lastCall.args[3], 'style-scope gr-diff range generated_b');
+      assert.equal(
+          lastCall.args[3],
+          'style-scope gr-diff range rangeHighlight generated_b'
+      );
+    });
+
+    test('long range comment', () => {
+      line.type = GrDiffLineType.ADD;
+      line.afterNumber = 65;
+      el.setAttribute('data-side', 'right');
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(
+          annotateElementStub.lastCall.args[3],
+          'style-scope gr-diff range generated_'
+      );
     });
   });
 
@@ -281,10 +314,10 @@
     assert.equal(element._rangesMap.left[39][0].start, 0);
     assert.equal(element._rangesMap.left[39][0].end, 9);
 
-    // The right has two ranged comments, one spanning ll.10-12 and the other
-    // on line 100.
+    // The right has four ranged comments: 10-12, 55-55, 60-71, 100-100
     const rightKeys = [];
     for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
+    for (let i = 60; i <= 71; i++) { rightKeys.push('' + i); }
     rightKeys.push('55', '100');
     assert.deepEqual(Object.keys(element._rangesMap.right).sort(),
         rightKeys.sort());
@@ -318,4 +351,3 @@
     assert.equal(range.end, line.text.length);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
index 70ee196..ee9e8f9 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
@@ -25,11 +25,11 @@
 $_documentContainer.innerHTML = `<dom-module id="gr-ranged-comment-theme">
   <template>
     <style>
-      .range {
+      .rangeHighlight {
         background-color: var(--diff-highlight-range-color);
         display: inline;
       }
-      .rangeHighlight {
+      .rangeHoverHighlight {
         background-color: var(--diff-highlight-range-hover-color);
         display: inline;
       }
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
index d702fb1..3b026b3 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
@@ -18,10 +18,9 @@
 import {GrTooltip} from '../../shared/gr-tooltip/gr-tooltip';
 import {customElement, property} from '@polymer/decorators';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-selection-action-box_html';
+import {fireEvent} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -36,9 +35,7 @@
 }
 
 @customElement('gr-selection-action-box')
-export class GrSelectionActionBox extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrSelectionActionBox extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -50,15 +47,13 @@
    */
 
   @property({type: Object})
-  keyEventTarget: Record<string, any> = document.body;
+  keyEventTarget = document.body;
 
   @property({type: Boolean})
   positionBelow = false;
 
-  /** @override */
-  created() {
-    super.created();
-
+  constructor() {
+    super();
     // See https://crbug.com/gerrit/4767
     this.addEventListener('mousedown', e => this._handleMouseDown(e));
   }
@@ -122,11 +117,6 @@
     } // 0 = main button
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('create-comment-requested', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'create-comment-requested');
   }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
index 5b76283..9229149 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
@@ -14,17 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-lib-loader/gr-lib-loader';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-syntax-layer_html';
 import {FILE, GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {CancelablePromise, util} from '../../../scripts/util';
-import {customElement, property} from '@polymer/decorators';
+import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
 import {DiffLayer, DiffLayerListener, HighlightJS} from '../../../types/types';
-import {DiffFileMetaInfo, DiffInfo} from '../../../types/common';
 import {GrLibLoader} from '../../shared/gr-lib-loader/gr-lib-loader';
 import {Side} from '../../../constants/constants';
 
@@ -160,56 +154,46 @@
   lastNotify: {left: number; right: number};
 }
 
-export interface GrSyntaxLayer {
-  $: {
-    libLoader: GrLibLoader;
-  };
-}
-
-@customElement('gr-syntax-layer')
-export class GrSyntaxLayer
-  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
-  implements DiffLayer {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object, observer: '_diffChanged'})
+export class GrSyntaxLayer implements DiffLayer {
   diff?: DiffInfo;
 
-  @property({type: Boolean})
   enabled = true;
 
-  @property({type: Array})
-  _baseRanges: SyntaxLayerRange[][] = [];
+  private baseRanges: SyntaxLayerRange[][] = [];
 
-  @property({type: Array})
-  _revisionRanges: SyntaxLayerRange[][] = [];
+  private revisionRanges: SyntaxLayerRange[][] = [];
 
-  @property({type: String})
-  _baseLanguage?: string;
+  private baseLanguage?: string;
 
-  @property({type: String})
-  _revisionLanguage?: string;
+  private revisionLanguage?: string;
 
-  @property({type: Array})
-  _listeners: DiffLayerListener[] = [];
+  private listeners: DiffLayerListener[] = [];
 
-  @property({type: Number})
-  _processHandle: number | null = null;
+  private processHandle: number | null = null;
 
-  @property({type: Object})
-  _processPromise: CancelablePromise<unknown> | null = null;
+  private processPromise: CancelablePromise<unknown> | null = null;
 
-  @property({type: Object})
-  _hljs?: HighlightJS;
+  private hljs?: HighlightJS;
+
+  private readonly libLoader = new GrLibLoader();
+
+  init(diff?: DiffInfo) {
+    this.cancel();
+    this.baseRanges = [];
+    this.revisionRanges = [];
+    this.diff = diff;
+  }
+
+  setEnabled(enabled: boolean) {
+    this.enabled = enabled;
+  }
 
   addListener(listener: DiffLayerListener) {
-    this.push('_listeners', listener);
+    this.listeners.push(listener);
   }
 
   removeListener(listener: DiffLayerListener) {
-    this._listeners = this._listeners.filter(f => f !== listener);
+    this.listeners = this.listeners.filter(f => f !== listener);
   }
 
   /**
@@ -218,9 +202,8 @@
    */
   annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
     if (!this.enabled) return;
-    if (line.beforeNumber === FILE) return;
-    if (line.afterNumber === FILE) return;
-
+    if (line.beforeNumber === FILE || line.afterNumber === FILE) return;
+    if (line.beforeNumber === 'LOST' || line.afterNumber === 'LOST') return;
     // Determine the side.
     let side;
     if (
@@ -238,13 +221,13 @@
 
     // Find the relevant syntax ranges, if any.
     let ranges: SyntaxLayerRange[] = [];
-    if (side === 'left' && this._baseRanges.length >= line.beforeNumber) {
-      ranges = this._baseRanges[line.beforeNumber - 1] || [];
+    if (side === 'left' && this.baseRanges.length >= line.beforeNumber) {
+      ranges = this.baseRanges[line.beforeNumber - 1] || [];
     } else if (
       side === 'right' &&
-      this._revisionRanges.length >= line.afterNumber
+      this.revisionRanges.length >= line.afterNumber
     ) {
-      ranges = this._revisionRanges[line.afterNumber - 1] || [];
+      ranges = this.revisionRanges[line.afterNumber - 1] || [];
     }
 
     // Apply the ranges to the element.
@@ -270,24 +253,24 @@
    */
   process() {
     // Cancel any still running process() calls, because they append to the
-    // same _baseRanges and _revisionRanges fields.
+    // same baseRanges and revisionRanges fields.
     this.cancel();
 
     // Discard existing ranges.
-    this._baseRanges = [];
-    this._revisionRanges = [];
+    this.baseRanges = [];
+    this.revisionRanges = [];
 
     if (!this.enabled || !this.diff?.content.length) {
       return Promise.resolve();
     }
 
     if (this.diff.meta_a) {
-      this._baseLanguage = this._getLanguage(this.diff.meta_a);
+      this.baseLanguage = this._getLanguage(this.diff.meta_a);
     }
     if (this.diff.meta_b) {
-      this._revisionLanguage = this._getLanguage(this.diff.meta_b);
+      this.revisionLanguage = this._getLanguage(this.diff.meta_b);
     }
-    if (!this._baseLanguage && !this._revisionLanguage) {
+    if (!this.baseLanguage && !this.revisionLanguage) {
       return Promise.resolve();
     }
 
@@ -300,14 +283,14 @@
       lastNotify: {left: 1, right: 1},
     };
 
-    const rangesCache = new Map();
+    const rangesCache = new Map<string, SyntaxLayerRange[]>();
 
-    this._processPromise = util.makeCancelable(
+    this.processPromise = util.makeCancelable(
       this._loadHLJS().then(
         () =>
-          new Promise(resolve => {
+          new Promise<void>(resolve => {
             const nextStep = () => {
-              this._processHandle = null;
+              this.processHandle = null;
               this._processNextLine(state, rangesCache);
 
               // Move to the next line in the section.
@@ -331,18 +314,18 @@
 
               if (state.lineIndex % 100 === 0) {
                 this._notify(state);
-                this._processHandle = this.async(nextStep, ASYNC_DELAY);
+                this.processHandle = window.setTimeout(nextStep, ASYNC_DELAY);
               } else {
                 nextStep.call(this);
               }
             };
 
-            this._processHandle = this.async(nextStep, 1);
+            this.processHandle = window.setTimeout(nextStep, 1);
           })
       )
     );
-    return this._processPromise.finally(() => {
-      this._processPromise = null;
+    return this.processPromise.finally(() => {
+      this.processPromise = null;
     });
   }
 
@@ -350,21 +333,15 @@
    * Cancel any asynchronous syntax processing jobs.
    */
   cancel() {
-    if (this._processHandle !== null) {
-      this.cancelAsync(this._processHandle);
-      this._processHandle = null;
+    if (this.processHandle !== null) {
+      clearTimeout(this.processHandle);
+      this.processHandle = null;
     }
-    if (this._processPromise) {
-      this._processPromise.cancel();
+    if (this.processPromise) {
+      this.processPromise.cancel();
     }
   }
 
-  _diffChanged() {
-    this.cancel();
-    this._baseRanges = [];
-    this._revisionRanges = [];
-  }
-
   /**
    * Take a string of HTML with the (potentially nested) syntax markers
    * Highlight.js emits and emit a list of text ranges and classes for the
@@ -427,7 +404,7 @@
     rangesCache: Map<string, SyntaxLayerRange[]>
   ) {
     if (!this.diff) return;
-    if (!this._hljs) return;
+    if (!this.hljs) return;
 
     let baseLine;
     let revisionLine;
@@ -446,44 +423,45 @@
         revisionLine = section.b[state.lineIndex];
         state.lineNums.right++;
       }
+      if (section.skip) {
+        state.lineNums.left += section.skip;
+        state.lineNums.right += section.skip;
+        for (let i = 0; i < section.skip; i++) this.revisionRanges.push([]);
+      }
     }
 
     // To store the result of the syntax highlighter.
     let result;
 
     if (
-      this._baseLanguage &&
+      this.baseLanguage &&
       baseLine !== undefined &&
-      this._hljs.getLanguage(this._baseLanguage)
+      this.hljs.getLanguage(this.baseLanguage)
     ) {
-      baseLine = this._workaround(this._baseLanguage, baseLine);
-      result = this._hljs.highlight(
-        this._baseLanguage,
+      baseLine = this._workaround(this.baseLanguage, baseLine);
+      result = this.hljs.highlight(
+        this.baseLanguage,
         baseLine,
         true,
         state.baseContext
       );
-      this.push(
-        '_baseRanges',
-        this._rangesFromString(result.value, rangesCache)
-      );
+      this.baseRanges.push(this._rangesFromString(result.value, rangesCache));
       state.baseContext = result.top;
     }
 
     if (
-      this._revisionLanguage &&
+      this.revisionLanguage &&
       revisionLine !== undefined &&
-      this._hljs.getLanguage(this._revisionLanguage)
+      this.hljs.getLanguage(this.revisionLanguage)
     ) {
-      revisionLine = this._workaround(this._revisionLanguage, revisionLine);
-      result = this._hljs.highlight(
-        this._revisionLanguage,
+      revisionLine = this._workaround(this.revisionLanguage, revisionLine);
+      result = this.hljs.highlight(
+        this.revisionLanguage,
         revisionLine,
         true,
         state.revisionContext
       );
-      this.push(
-        '_revisionRanges',
+      this.revisionRanges.push(
         this._rangesFromString(result.value, rangesCache)
       );
       state.revisionContext = result.top;
@@ -499,7 +477,7 @@
    * with code it shouldn't AND to avoid executing regexes as much as
    * possible.
    * * These tests should document the issue clearly enough that the test can
-   * be condidently removed when the issue is solved in HLJS.
+   * be confidently removed when the issue is solved in HLJS.
    * * These tests should rewrite the line of code to have the same number of
    * characters. This method rewrites the string that gets parsed, but NOT
    * the string that gets displayed and highlighted. Thus, the positions
@@ -593,20 +571,14 @@
   }
 
   _notifyRange(start: number, end: number, side: Side) {
-    for (const listener of this._listeners) {
+    for (const listener of this.listeners) {
       listener(start, end, side);
     }
   }
 
   _loadHLJS() {
-    return this.$.libLoader.getHLJS().then(hljs => {
-      this._hljs = hljs;
+    return this.libLoader.getHLJS().then(hljs => {
+      this.hljs = hljs;
     });
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-syntax-layer': GrSyntaxLayer;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
index 6a2bbca..f9100a2 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
@@ -20,8 +20,7 @@
 import './gr-syntax-layer.js';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-
-const basicFixture = fixtureFromElement('gr-syntax-layer');
+import {GrSyntaxLayer} from './gr-syntax-layer.js';
 
 suite('gr-syntax-layer tests', () => {
   let diff;
@@ -48,7 +47,7 @@
   }
 
   setup(() => {
-    element = basicFixture.instantiate();
+    element = new GrSyntaxLayer();
     diff = getMockDiffResponse();
     element.diff = diff;
   });
@@ -76,7 +75,7 @@
     el.textContent = str;
     const line = new GrDiffLine(GrDiffLineType.REMOVE);
     line.beforeNumber = 12;
-    element._baseRanges[11] = [{
+    element.baseRanges[11] = [{
       start,
       length,
       className,
@@ -103,7 +102,7 @@
     el.textContent = str;
     const line = new GrDiffLine(GrDiffLineType.REMOVE);
     line.beforeNumber = 12;
-    element._baseRanges[11] = [{
+    element.baseRanges[11] = [{
       start,
       length,
       className,
@@ -127,8 +126,8 @@
 
     processPromise.then(() => {
       assert.isFalse(processNextSpy.called);
-      assert.equal(element._baseRanges.length, 0);
-      assert.equal(element._revisionRanges.length, 0);
+      assert.equal(element.baseRanges.length, 0);
+      assert.equal(element.revisionRanges.length, 0);
       done();
     });
   });
@@ -145,8 +144,8 @@
 
     processPromise.then(() => {
       assert.isFalse(processNextSpy.called);
-      assert.equal(element._baseRanges.length, 0);
-      assert.equal(element._revisionRanges.length, 0);
+      assert.equal(element.baseRanges.length, 0);
+      assert.equal(element.revisionRanges.length, 0);
       done();
     });
   });
@@ -160,8 +159,8 @@
 
     processPromise.then(() => {
       assert.isFalse(processNextSpy.called);
-      assert.equal(element._baseRanges.length, 0);
-      assert.equal(element._revisionRanges.length, 0);
+      assert.equal(element.baseRanges.length, 0);
+      assert.equal(element.revisionRanges.length, 0);
       assert.isFalse(loadHLJSSpy.called);
       done();
     });
@@ -173,7 +172,7 @@
 
     const mockHLJS = getMockHLJS();
     const highlightSpy = sinon.spy(mockHLJS, 'highlight');
-    sinon.stub(element.$.libLoader, 'getHLJS').callsFake(
+    sinon.stub(element.libLoader, 'getHLJS').callsFake(
         () => Promise.resolve(mockHLJS));
     const processNextSpy = sinon.spy(element, '_processNextLine');
     const processPromise = element.process();
@@ -183,13 +182,13 @@
       const linesB = diff.meta_b.lines;
 
       assert.isTrue(processNextSpy.called);
-      assert.equal(element._baseRanges.length, linesA);
-      assert.equal(element._revisionRanges.length, linesB);
+      assert.equal(element.baseRanges.length, linesA);
+      assert.equal(element.revisionRanges.length, linesB);
 
       assert.equal(highlightSpy.callCount, linesA + linesB);
 
       // The first line of both sides have a range.
-      let ranges = [element._baseRanges[0], element._revisionRanges[0]];
+      let ranges = [element.baseRanges[0], element.revisionRanges[0]];
       for (const range of ranges) {
         assert.equal(range.length, 1);
         assert.equal(range[0].className,
@@ -200,8 +199,8 @@
 
       // 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));
+      ranges = element.baseRanges.slice(1, 12)
+          .concat(element.revisionRanges.slice(1, 11));
 
       for (const range of ranges) {
         assert.equal(range.length, 0);
@@ -209,7 +208,7 @@
 
       // 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]];
+      ranges = [element.baseRanges[13], element.revisionRanges[12]];
 
       for (const range of ranges) {
         assert.equal(range.length, 1);
@@ -221,13 +220,13 @@
 
       // The next group should have a similar instance on either side.
 
-      let range = element._baseRanges[15];
+      let range = element.baseRanges[15];
       assert.equal(range.length, 1);
       assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
       assert.equal(range[0].start, 34);
       assert.equal(range[0].length, 'ipsum'.length);
 
-      range = element._revisionRanges[14];
+      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);
@@ -237,9 +236,9 @@
     });
   });
 
-  test('_diffChanged calls cancel', () => {
-    const cancelSpy = sinon.spy(element, '_diffChanged');
-    element.diff = {content: []};
+  test('init calls cancel', () => {
+    const cancelSpy = sinon.spy(element, 'cancel');
+    element.init({content: []});
     assert.isTrue(cancelSpy.called);
   });
 
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
index 5967b03..7e4edd6 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -17,9 +17,6 @@
 import '../../../styles/gr-table-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-list-view/gr-list-view';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-documentation-search_html';
 import {
@@ -28,18 +25,12 @@
 } from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {getBaseUrl} from '../../../utils/url-util';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {DocResult} from '../../../types/common';
+import {fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
 
-export interface GrDocumentationSearch {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-documentation-search')
-export class GrDocumentationSearch extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrDocumentationSearch extends ListViewMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -59,12 +50,12 @@
   @property({type: String})
   _filter = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {detail: {title: 'Documentation Search'}})
-    );
+  connectedCallback() {
+    super.connectedCallback();
+    fireTitleChange(this, 'Documentation Search');
   }
 
   _paramsChanged(params: ListViewParams) {
@@ -76,14 +67,16 @@
 
   _getDocumentationSearches(filter: string) {
     this._documentationSearches = [];
-    return this.$.restAPI.getDocumentationSearches(filter).then(searches => {
-      // Late response.
-      if (filter !== this._filter || !searches) {
-        return;
-      }
-      this._documentationSearches = searches;
-      this._loading = false;
-    });
+    return this.restApiService
+      .getDocumentationSearches(filter)
+      .then(searches => {
+        // Late response.
+        if (filter !== this._filter || !searches) {
+          return;
+        }
+        this._documentationSearches = searches;
+        this._loading = false;
+      });
   }
 
   _computeSearchUrl(url?: string) {
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
index 9f5aae3..dd1faeb 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
@@ -54,5 +54,4 @@
       </tbody>
     </table>
   </gr-list-view>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
index ce80f2f..f5e47ca 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
@@ -19,6 +19,7 @@
 import './gr-documentation-search.js';
 import {page} from '../../../utils/page-wrapper-utils.js';
 import 'lodash/lodash.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-documentation-search');
 
@@ -45,11 +46,8 @@
   suite('list with searches for documentation', () => {
     setup(done => {
       documentationSearches = _.times(26, documentationGenerator);
-      stub('gr-rest-api-interface', {
-        getDocumentationSearches() {
-          return Promise.resolve(documentationSearches);
-        },
-      });
+      stubRestApi('getDocumentationSearches').returns(
+          Promise.resolve(documentationSearches));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -70,19 +68,12 @@
       _.times(1, documentationSearches);
     });
 
-    test('_paramsChanged', done => {
-      sinon.stub(
-          element.$.restAPI,
-          'getDocumentationSearches')
-          .callsFake(() => Promise.resolve(documentationSearches));
-      const value = {
-        filter: 'test',
-      };
-      element._paramsChanged(value).then(() => {
-        assert.isTrue(element.$.restAPI.getDocumentationSearches.lastCall
-            .calledWithExactly('test'));
-        done();
-      });
+    test('_paramsChanged', async () => {
+      const stub = stubRestApi('getDocumentationSearches').returns(
+          Promise.resolve(documentationSearches));
+      const value = {filter: 'test'};
+      await element._paramsChanged(value);
+      assert.isTrue(stub.lastCall.calledWithExactly('test'));
     });
   });
 
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
index 4c63303..2828918 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
@@ -15,8 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-default-editor_html';
 import {customElement, property} from '@polymer/decorators';
@@ -32,10 +30,7 @@
 }
 
 @customElement('gr-default-editor')
-/** @extends PolymerElement */
-export class GrDefaultEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDefaultEditor extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.ts
index ba8af3c..77b4bc6 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.ts
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.ts
@@ -23,7 +23,8 @@
       box-sizing: border-box;
       font-family: var(--monospace-font-family);
       font-size: var(--font-size-code);
-      line-height: var(--line-height-code);
+      /* usually 16px = 12px + 4px */
+      line-height: calc(var(--font-size-code) + var(--spacing-s));
       min-height: 60vh;
       resize: none;
       white-space: pre;
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index 7723050..f8838a3 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -20,11 +20,8 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-edit-controls_html';
 import {GrEditAction, GrEditConstants} from '../gr-edit-constants';
@@ -33,15 +30,14 @@
 import {ChangeInfo, PatchSetNum} from '../../../types/common';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   AutocompleteQuery,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
+import {appContext} from '../../../services/app-context';
 
 export interface GrEditControls {
   $: {
-    restAPI: RestApiService & Element;
     overlay: GrOverlay;
     openDialog: GrDialog;
     deleteDialog: GrDialog;
@@ -51,9 +47,7 @@
 }
 
 @customElement('gr-edit-controls')
-export class GrEditControls extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrEditControls extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -79,6 +73,8 @@
   @property({type: Object})
   _query: AutocompleteQuery;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = (input: string) => this._queryFiles(input);
@@ -167,16 +163,16 @@
       if (autocomplete) {
         autocomplete.focus();
       }
-      this.async(() => {
+      setTimeout(() => {
         this.$.overlay.center();
       }, 1);
     });
   }
 
   _hideAllDialogs() {
-    const dialogs = this.root!.querySelectorAll('.dialog') as NodeListOf<
-      GrDialog
-    >;
+    const dialogs = this.root!.querySelectorAll(
+      '.dialog'
+    ) as NodeListOf<GrDialog>;
     for (const dialog of dialogs) {
       this._closeDialog(dialog);
     }
@@ -221,7 +217,7 @@
       this._closeDialog(this.$.openDialog, true);
       return;
     }
-    return this.$.restAPI
+    return this.restApiService
       .saveFileUploadChangeEdit(this.change._number, path, fileData)
       .then(res => {
         if (!res || !res.ok) {
@@ -236,7 +232,7 @@
     // Get the dialog before the api call as the event will change during bubbling
     // which will make Polymer.dom(e).path an empty array in polymer 2
     const dialog = this._getDialogFromEvent(e);
-    this.$.restAPI
+    this.restApiService
       .deleteFileInChangeEdit(this.change._number, this._path)
       .then(res => {
         if (!res || !res.ok) {
@@ -249,7 +245,7 @@
 
   _handleRestoreConfirm(e: Event) {
     const dialog = this._getDialogFromEvent(e);
-    this.$.restAPI
+    this.restApiService
       .restoreFileInChangeEdit(this.change._number, this._path)
       .then(res => {
         if (!res || !res.ok) {
@@ -262,7 +258,7 @@
 
   _handleRenameConfirm(e: Event) {
     const dialog = this._getDialogFromEvent(e);
-    return this.$.restAPI
+    return this.restApiService
       .renameFileInChangeEdit(this.change._number, this._path, this._newPath)
       .then(res => {
         if (!res || !res.ok) {
@@ -274,10 +270,11 @@
   }
 
   _queryFiles(input: string): Promise<AutocompleteSuggestion[]> {
-    return this.$.restAPI
+    return this.restApiService
       .queryChangeFiles(this.change._number, this.patchNum, input)
       .then(res => {
-        if (!res) throw new Error('Failed to retrieve files. Reponse not set.');
+        if (!res)
+          throw new Error('Failed to retrieve files. Response not set.');
         return res.map(file => {
           return {name: file};
         });
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
index 1d86d61..0989f63 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
@@ -185,5 +185,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
index 98d1545..bbf4790 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
@@ -19,7 +19,7 @@
 import './gr-edit-controls.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {createIronOverlayBackdropStyleEl} from '../../../test/test-utils.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-edit-controls');
 
@@ -29,24 +29,17 @@
   let showDialogSpy;
   let closeDialogSpy;
   let queryStub;
-  let ironOverlayBackdropStyleEl;
 
   setup(() => {
-    ironOverlayBackdropStyleEl = createIronOverlayBackdropStyleEl();
     element = basicFixture.instantiate();
     element.change = {_number: '42'};
     showDialogSpy = sinon.spy(element, '_showDialog');
     closeDialogSpy = sinon.spy(element, '_closeDialog');
     sinon.stub(element, '_hideAllDialogs');
-    queryStub = sinon.stub(element.$.restAPI, 'queryChangeFiles')
-        .returns(Promise.resolve([]));
+    queryStub = stubRestApi('queryChangeFiles').returns(Promise.resolve([]));
     flush();
   });
 
-  teardown(() => {
-    ironOverlayBackdropStyleEl.remove();
-  });
-
   test('all actions exist', () => {
     // We take 1 away from the total found, due to an extra button being
     // added for the file uploads (browse).
@@ -57,14 +50,14 @@
 
   suite('edit button CUJ', () => {
     let navStubs;
-    let openAutoCcmplete;
+    let openAutoComplete;
 
     setup(() => {
       navStubs = [
         sinon.stub(GerritNav, 'getEditUrlForDiff'),
         sinon.stub(GerritNav, 'navigateToRelativeUrl'),
       ];
-      openAutoCcmplete = element.$.openDialog.querySelector('gr-autocomplete');
+      openAutoComplete = element.$.openDialog.querySelector('gr-autocomplete');
     });
 
     test('_isValidPath', () => {
@@ -84,9 +77,9 @@
         assert.isFalse(queryStub.called);
         // Setup _focused manually - in headless mode Chrome sometimes don't
         // setup focus. flush and/or flushAsynchronousOperations don't help
-        openAutoCcmplete._focused = true;
-        openAutoCcmplete.noDebounce = true;
-        openAutoCcmplete.text = 'src/test.cpp';
+        openAutoComplete._focused = true;
+        openAutoComplete.noDebounce = true;
+        openAutoComplete.text = 'src/test.cpp';
         assert.isTrue(queryStub.called);
         assert.isFalse(element.$.openDialog.disabled);
         MockInteractions.tap(element.$.openDialog.shadowRoot
@@ -102,8 +95,8 @@
       MockInteractions.tap(element.shadowRoot.querySelector('#open'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.openDialog.disabled);
-        openAutoCcmplete.noDebounce = true;
-        openAutoCcmplete.text = 'src/test.cpp';
+        openAutoComplete.noDebounce = true;
+        openAutoComplete.text = 'src/test.cpp';
         assert.isFalse(element.$.openDialog.disabled);
         MockInteractions.tap(element.$.openDialog.shadowRoot
             .querySelector('gr-button'));
@@ -121,7 +114,7 @@
 
     setup(() => {
       navStub = sinon.stub(GerritNav, 'navigateToChange');
-      deleteStub = sinon.stub(element.$.restAPI, 'deleteFileInChangeEdit');
+      deleteStub = stubRestApi('deleteFileInChangeEdit');
       deleteAutocomplete =
           element.$.deleteDialog.querySelector('gr-autocomplete');
     });
@@ -205,7 +198,7 @@
 
     setup(() => {
       navStub = sinon.stub(GerritNav, 'navigateToChange');
-      renameStub = sinon.stub(element.$.restAPI, 'renameFileInChangeEdit');
+      renameStub = stubRestApi('renameFileInChangeEdit');
       renameAutocomplete =
           element.$.renameDialog.querySelector('gr-autocomplete');
     });
@@ -298,7 +291,8 @@
 
     setup(() => {
       navStub = sinon.stub(GerritNav, 'navigateToChange');
-      restoreStub = sinon.stub(element.$.restAPI, 'restoreFileInChangeEdit');
+      restoreStub = stubRestApi(
+          'restoreFileInChangeEdit');
     });
 
     test('restore hidden by default', () => {
@@ -362,7 +356,7 @@
 
     setup(() => {
       navStub = sinon.stub(GerritNav, 'navigateToChange');
-      fileStub = sinon.stub(element.$.restAPI, 'saveFileUploadChangeEdit');
+      fileStub = stubRestApi('saveFileUploadChangeEdit');
     });
 
     test('_handleUploadConfirm', () => {
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
index 9f3d1bc..d2e1d64 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
@@ -17,8 +17,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-edit-file-controls_html';
 import {GrEditConstants} from '../gr-edit-constants';
@@ -29,11 +27,8 @@
   id: string;
 }
 
-/** @extends PolymerElement */
 @customElement('gr-edit-file-controls')
-class GrEditFileControls extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrEditFileControls extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
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 047a818..553409f 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -18,12 +18,8 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-editable-label/gr-editable-label';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import '../../shared/gr-storage/gr-storage';
 import '../gr-default-editor/gr-default-editor';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-editor-view_html';
 import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
@@ -31,22 +27,22 @@
   GerritNav,
   GenerateUrlEditViewParameters,
 } from '../../core/gr-navigation/gr-navigation';
-import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util';
 import {computeTruncatedPath} from '../../../utils/path-list-util';
 import {customElement, property} from '@polymer/decorators';
 import {
-  RestApiService,
-  ErrorCallback,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {
   ChangeInfo,
   PatchSetNum,
   EditPreferencesInfo,
   Base64FileContent,
   NumericChangeId,
+  EditPatchSetNum,
 } from '../../../types/common';
-import {GrStorage} from '../../shared/gr-storage/gr-storage';
 import {HttpMethod, NotifyType} from '../../../constants/constants';
+import {fireAlert, fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
+import {assertIsDefined} from '../../../utils/common-util';
+import {debounce, DelayedTask} from '../../../utils/async-util';
 import {changeIsMerged, changeIsAbandoned} from '../../../utils/change-util';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
@@ -58,16 +54,8 @@
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
 
-export interface GrEditorView {
-  $: {
-    restAPI: RestApiService & Element;
-    storage: GrStorage;
-  };
-}
 @customElement('gr-editor-view')
-export class GrEditorView extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrEditorView extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -126,38 +114,51 @@
   @property({type: Number})
   _lineNum?: number;
 
+  private readonly restApiService = appContext.restApiService;
+
+  private readonly storage = appContext.storageService;
+
+  private storeTask?: DelayedTask;
+
+  reporting = appContext.reportingService;
+
   get keyBindings() {
     return {
       'ctrl+s meta+s': '_handleSaveShortcut',
     };
   }
 
-  /** @override */
-  created() {
-    super.created();
+  constructor() {
+    super();
     this.addEventListener('content-change', e => {
       this._handleContentChange(e as CustomEvent<{value: string}>);
     });
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._getEditPrefs().then(prefs => {
       this._prefs = prefs;
     });
   }
 
+  /** @override */
+  disconnectedCallback() {
+    this.storeTask?.cancel();
+    super.disconnectedCallback();
+  }
+
   get storageKey() {
     return `c${this._changeNum}_ps${this._patchNum}_${this._path}`;
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   _getEditPrefs() {
-    return this.$.restAPI.getEditPreferences();
+    return this.restApiService.getEditPreferences();
   }
 
   _paramsChanged(value: GenerateUrlEditViewParameters) {
@@ -167,23 +168,16 @@
 
     this._changeNum = value.changeNum;
     this._path = value.path;
-    this._patchNum =
-      value.patchNum || (SPECIAL_PATCH_SET_NUM.EDIT as PatchSetNum);
+    this._patchNum = value.patchNum || (EditPatchSetNum as PatchSetNum);
     this._lineNum =
       typeof value.lineNum === 'string' ? Number(value.lineNum) : value.lineNum;
 
     // NOTE: This may be called before attachment (e.g. while parentElement is
     // null). Fire title-change in an async so that, if attachment to the DOM
     // has been queued, the event can bubble up to the handler in gr-app.
-    this.async(() => {
+    setTimeout(() => {
       const title = `Editing ${computeTruncatedPath(value.path)}`;
-      this.dispatchEvent(
-        new CustomEvent('title-change', {
-          detail: {title},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireTitleChange(this, title);
     });
 
     const promises = [];
@@ -196,7 +190,7 @@
   }
 
   _getChangeDetail(changeNum: NumericChangeId) {
-    return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+    return this.restApiService.getDiffChangeDetail(changeNum).then(change => {
       this._change = change;
     });
   }
@@ -204,20 +198,15 @@
   _editChange(value?: ChangeInfo | null) {
     if (!changeIsMerged(value) && !changeIsAbandoned(value)) return;
     if (!value) return;
-    const message =
-      'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.';
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {message},
-        bubbles: true,
-        composed: true,
-      })
+    fireAlert(
+      this,
+      'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.'
     );
     GerritNav.navigateToChange(value);
   }
 
   _handlePathChanged(e: CustomEvent<string>) {
-    // TODO(TS) could be cleand up, it was added for type requirements
+    // TODO(TS) could be cleaned up, it was added for type requirements
     if (this._changeNum === undefined || !this._path) {
       return Promise.reject(new Error('changeNum or path undefined'));
     }
@@ -225,7 +214,7 @@
     if (path === this._path) {
       return Promise.resolve();
     }
-    return this.$.restAPI
+    return this.restApiService
       .renameFileInChangeEdit(this._changeNum, this._path, path)
       .then(res => {
         if (!res || !res.ok) {
@@ -250,11 +239,9 @@
     if (patchNum === undefined) {
       return Promise.reject(new Error('patchNum undefined'));
     }
-    const storedContent = this.$.storage.getEditableContentItem(
-      this.storageKey
-    );
+    const storedContent = this.storage.getEditableContentItem(this.storageKey);
 
-    return this.$.restAPI
+    return this.restApiService
       .getFileContent(changeNum, path, patchNum)
       .then(res => {
         const content = (res && (res as Base64FileContent).content) || '';
@@ -263,13 +250,7 @@
           storedContent.message &&
           storedContent.message !== content
         ) {
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {message: RESTORED_MESSAGE},
-              bubbles: true,
-              composed: true,
-            })
-          );
+          fireAlert(this, RESTORED_MESSAGE);
 
           this._newContent = storedContent.message;
         } else {
@@ -294,10 +275,10 @@
     }
     this._saving = true;
     this._showAlert(SAVING_MESSAGE);
-    this.$.storage.eraseEditableContentItem(this.storageKey);
+    this.storage.eraseEditableContentItem(this.storageKey);
     if (!this._newContent)
       return Promise.reject(new Error('new content undefined'));
-    return this.$.restAPI
+    return this.restApiService
       .saveChangeEdit(this._changeNum, this._path, this._newContent)
       .then(res => {
         this._saving = false;
@@ -313,13 +294,7 @@
   }
 
   _showAlert(message: string) {
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {message},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireAlert(this, message);
   }
 
   _computeSaveDisabled(
@@ -350,18 +325,18 @@
   }
 
   _handlePublishTap() {
-    if (!this._changeNum) throw new Error('missing changeNum');
+    assertIsDefined(this._changeNum, '_changeNum');
 
     const changeNum = this._changeNum;
     this._saveEdit().then(() => {
       const handleError: ErrorCallback = response => {
         this._showAlert(PUBLISH_FAILED_MSG);
-        console.error(response);
+        this.reporting.error(new Error(response?.statusText));
       };
 
       this._showAlert(PUBLISHING_EDIT_MSG);
 
-      this.$.restAPI
+      this.restApiService
         .executeChangeAction(
           changeNum,
           HttpMethod.POST,
@@ -371,22 +346,22 @@
           handleError
         )
         .then(() => {
-          if (!this._change) throw new Error('missing change');
+          assertIsDefined(this._change, '_change');
           GerritNav.navigateToChange(this._change);
         });
     });
   }
 
   _handleContentChange(e: CustomEvent<{value: string}>) {
-    this.debounce(
-      'store',
+    this.storeTask = debounce(
+      this.storeTask,
       () => {
         const content = e.detail.value;
         if (content) {
           this.set('_newContent', e.detail.value);
-          this.$.storage.setEditableContentItem(this.storageKey, content);
+          this.storage.setEditableContentItem(this.storageKey, content);
         } else {
-          this.$.storage.eraseEditableContentItem(this.storageKey);
+          this.storage.eraseEditableContentItem(this.storageKey);
         }
       },
       STORAGE_DEBOUNCE_INTERVAL_MS
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
index 2455f61..fc6a9d9 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
@@ -125,6 +125,4 @@
       ></gr-default-editor>
     </gr-endpoint-decorator>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
 `;
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
index c0f615d..8ae9827 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
@@ -18,8 +18,9 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-editor-view.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
 import {HttpMethod} from '../../../constants/constants.js';
+import {stubRestApi} from '../../../test/test-utils.js';
+import {EditPatchSetNum} from '../../../types/common.js';
 
 const basicFixture = fixtureFromElement('gr-editor-view');
 
@@ -37,15 +38,11 @@
   };
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-      getEditPreferences() { return Promise.resolve({}); },
-    });
-
     element = basicFixture.instantiate();
-    savePathStub = sinon.stub(element.$.restAPI, 'renameFileInChangeEdit');
-    saveFileStub = sinon.stub(element.$.restAPI, 'saveChangeEdit');
-    changeDetailStub = sinon.stub(element.$.restAPI, 'getDiffChangeDetail');
+    savePathStub = stubRestApi('renameFileInChangeEdit');
+    saveFileStub = stubRestApi('saveChangeEdit');
+    changeDetailStub = stubRestApi(
+        'getDiffChangeDetail');
     navigateStub = sinon.stub(element, '_viewEditInChangeView');
   });
 
@@ -106,13 +103,13 @@
   });
 
   test('reacts to content-change event', () => {
-    const storeStub = sinon.spy(element.$.storage, 'setEditableContentItem');
+    const storeStub = sinon.spy(element.storage, 'setEditableContentItem');
     element._newContent = 'test';
     element.$.editorEndpoint.dispatchEvent(new CustomEvent('content-change', {
       bubbles: true, composed: true,
       detail: {value: 'new content value'},
     }));
-    element.flushDebouncer('store');
+    element.storeTask.flush();
     flush();
 
     assert.equal(element._newContent, 'new content value');
@@ -139,7 +136,7 @@
 
     test('file modification and save, !ok response', () => {
       const saveSpy = sinon.spy(element, '_saveEdit');
-      const eraseStub = sinon.stub(element.$.storage,
+      const eraseStub = sinon.stub(element.storage,
           'eraseEditableContentItem');
       const alertStub = sinon.stub(element, '_showAlert');
       saveFileStub.returns(Promise.resolve({ok: false}));
@@ -199,7 +196,7 @@
       const saveSpy = sinon.spy(element, '_saveEdit');
       const alertStub = sinon.stub(element, '_showAlert');
       const changeActionsStub =
-        sinon.stub(element.$.restAPI, 'executeChangeAction');
+        stubRestApi('executeChangeAction');
       saveFileStub.returns(Promise.resolve({ok: true}));
       element._newContent = newText;
       flush();
@@ -251,11 +248,11 @@
       element._newContent = 'initial';
       element._content = 'initial';
       element._type = 'initial';
-      sinon.stub(element.$.storage, 'getEditableContentItem').returns(null);
+      sinon.stub(element.storage, 'getEditableContentItem').returns(null);
     });
 
     test('res.ok', () => {
-      sinon.stub(element.$.restAPI, 'getFileContent')
+      stubRestApi('getFileContent')
           .returns(Promise.resolve({
             ok: true,
             type: 'text/javascript',
@@ -271,7 +268,7 @@
     });
 
     test('!res.ok', () => {
-      sinon.stub(element.$.restAPI, 'getFileContent')
+      stubRestApi('getFileContent')
           .returns(Promise.resolve({}));
 
       // Ensure no data is set with a bad response.
@@ -283,7 +280,7 @@
     });
 
     test('content is undefined', () => {
-      sinon.stub(element.$.restAPI, 'getFileContent')
+      stubRestApi('getFileContent')
           .returns(Promise.resolve({
             ok: true,
             type: 'text/javascript',
@@ -297,7 +294,7 @@
     });
 
     test('content and type is undefined', () => {
-      sinon.stub(element.$.restAPI, 'getFileContent')
+      stubRestApi('getFileContent')
           .returns(Promise.resolve({
             ok: true,
           }));
@@ -324,7 +321,7 @@
     element._change = {};
     navigateStub.restore();
     const navStub = sinon.stub(GerritNav, 'navigateToChange');
-    element._patchNum = SPECIAL_PATCH_SET_NUM.EDIT;
+    element._patchNum = EditPatchSetNum;
     element._viewEditInChangeView();
     assert.equal(navStub.lastCall.args[1], undefined);
     assert.equal(navStub.lastCall.args[3], true);
@@ -375,9 +372,9 @@
 
   suite('gr-storage caching', () => {
     test('local edit exists', () => {
-      sinon.stub(element.$.storage, 'getEditableContentItem')
+      sinon.stub(element.storage, 'getEditableContentItem')
           .returns({message: 'pending edit'});
-      sinon.stub(element.$.restAPI, 'getFileContent')
+      stubRestApi('getFileContent')
           .returns(Promise.resolve({
             ok: true,
             type: 'text/javascript',
@@ -398,9 +395,9 @@
     });
 
     test('local edit exists, is same as remote edit', () => {
-      sinon.stub(element.$.storage, 'getEditableContentItem')
+      sinon.stub(element.storage, 'getEditableContentItem')
           .returns({message: 'pending edit'});
-      sinon.stub(element.$.restAPI, 'getFileContent')
+      stubRestApi('getFileContent')
           .returns(Promise.resolve({
             ok: true,
             type: 'text/javascript',
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index c2fb124..e96f6e4 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -37,10 +37,6 @@
 import './settings/gr-cla-view/gr-cla-view';
 import './settings/gr-registration-dialog/gr-registration-dialog';
 import './settings/gr-settings-view/gr-settings-view';
-import './shared/gr-lib-loader/gr-lib-loader';
-import './shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-app-element_html';
 import {getBaseUrl} from '../utils/url-util';
@@ -49,11 +45,10 @@
   Shortcut,
   SPECIAL_SHORTCUT,
 } from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {GerritNav, GerritView} from './core/gr-navigation/gr-navigation';
+import {GerritNav} from './core/gr-navigation/gr-navigation';
 import {appContext} from '../services/app-context';
 import {flush} from '@polymer/polymer/lib/utils/flush';
 import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../services/services/gr-rest-api/gr-rest-api';
 import {GrRouter} from './core/gr-router/gr-router';
 import {
   AccountDetailInfo,
@@ -77,8 +72,14 @@
   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';
 
 interface ErrorInfo {
   text: string;
@@ -88,7 +89,6 @@
 
 export interface GrAppElement {
   $: {
-    restAPI: RestApiService & Element;
     router: GrRouter;
     errorManager: GrErrorManager;
     errorView: HTMLDivElement;
@@ -98,9 +98,7 @@
 
 // TODO(TS): implement AppElement interface from gr-app-types.ts
 @customElement('gr-app-element')
-export class GrAppElement extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrAppElement extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -192,8 +190,22 @@
   @property({type: Boolean})
   loadKeyboardShortcutsDialog = false;
 
+  // TODO(milutin) - remove once new gr-dialog will do it out of the box
+  // This removes footer, header from a11y tree, when a dialog on view
+  // (e.g. reply dialog) is open
+  @property({type: Boolean})
+  _footerHeaderAriaHidden = false;
+
+  // TODO(milutin) - remove once new gr-dialog will do it out of the box
+  // This removes main page from a11y tree, when a dialog on gr-app-element
+  // (e.g. shortcut dialog) is open
+  @property({type: Boolean})
+  _mainAriaHidden = false;
+
   private reporting = appContext.reportingService;
 
+  private readonly restApiService = appContext.restApiService;
+
   keyboardShortcuts() {
     return {
       [Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
@@ -205,23 +217,33 @@
     };
   }
 
-  /** @override */
-  created() {
-    super.created();
+  constructor() {
+    super();
+    // We just want to instantiate this service somewhere. It is reacting to
+    // model changes and updates the config model, but at the moment the service
+    // is not called from anywhere.
+    appContext.configService;
     this._bindKeyboardShortcuts();
-    this.addEventListener('page-error', e => this._handlePageError(e));
-    this.addEventListener('title-change', e => this._handleTitleChange(e));
+    document.addEventListener(EventType.PAGE_ERROR, e => {
+      this._handlePageError(e);
+    });
+    this.addEventListener(EventType.TITLE_CHANGE, e => {
+      this._handleTitleChange(e);
+    });
+    this.addEventListener(EventType.DIALOG_CHANGE, e => {
+      this._handleDialogChange(e as CustomEvent<DialogChangeEventDetail>);
+    });
     this.addEventListener('location-change', e =>
       this._handleLocationChange(e)
     );
-    this.addEventListener('rpc-log', e => this._handleRpcLog(e));
+    document.addEventListener('gr-rpc-log', e => this._handleRpcLog(e));
     this.addEventListener('shortcut-triggered', e =>
       this._handleShortcutTriggered(e)
     );
     // Ideally individual views should handle this event and respond with a soft
     // reload. This is a catch-all for all views that cannot or have not
     // implemented that.
-    this.addEventListener('reload', () => window.location.reload());
+    this.addEventListener('reload', () => windowLocationReload());
   }
 
   /** @override */
@@ -231,19 +253,22 @@
     this.reporting.appStarted();
     this.$.router.start();
 
-    this.$.restAPI.getAccount().then(account => {
+    this.restApiService.getAccount().then(account => {
       this._account = account;
-      const role = account ? 'user' : 'guest';
-      this.reporting.reportLifeCycle(`Started as ${role}`);
+      if (account) {
+        this.reporting.reportLifeCycle(LifeCycle.STARTED_AS_USER);
+      } else {
+        this.reporting.reportLifeCycle(LifeCycle.STARTED_AS_GUEST);
+      }
     });
-    this.$.restAPI.getConfig().then(config => {
+    this.restApiService.getConfig().then(config => {
       this._serverConfig = config;
 
       if (config && config.gerrit && config.gerrit.report_bug_url) {
         this._feedbackUrl = config.gerrit.report_bug_url;
       }
     });
-    this.$.restAPI.getVersion().then(version => {
+    this.restApiService.getVersion().then(version => {
       this._version = version;
       this._logWelcome();
     });
@@ -272,9 +297,7 @@
         offset: 0,
         selectedChangeIndex: 0,
       },
-      dashboardView: {
-        selectedChangeIndex: 0,
-      },
+      dashboardView: {},
     };
   }
 
@@ -373,7 +396,7 @@
     }
     this.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
     this.bindShortcut(Shortcut.PREV_CHUNK, 'p');
-    this.bindShortcut(Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+    this.bindShortcut(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, 'shift+x');
     this.bindShortcut(Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
     this.bindShortcut(Shortcut.PREV_COMMENT_THREAD, 'shift+p');
     this.bindShortcut(
@@ -433,9 +456,9 @@
     if (!account) return;
 
     // Preferences are cached when a user is logged in; warm them.
-    this.$.restAPI.getPreferences();
-    this.$.restAPI.getDiffPreferences();
-    this.$.restAPI.getEditPreferences();
+    this.restApiService.getPreferences();
+    this.restApiService.getDiffPreferences();
+    this.restApiService.getEditPreferences();
     this.$.errorManager.knownAccountId =
       (this._account && this._account._account_id) || null;
   }
@@ -463,7 +486,7 @@
     // because _showPluginScreen value does not change. To force restamp,
     // change _showPluginScreen value between true and false.
     if (isPluginScreen) {
-      this.async(() => this.set('_showPluginScreen', true), 1);
+      setTimeout(() => this.set('_showPluginScreen', true), 1);
     }
     this.set(
       '_showDocumentationSearch',
@@ -487,6 +510,9 @@
         registrationOverlay.refit();
       });
     }
+    // To fix bug announce read after each new view, we reset announce with
+    // empty space
+    fireIronAnnounce(this, ' ');
   }
 
   _handleShortcutTriggered(event: ShortcutTriggeredEvent) {
@@ -499,11 +525,10 @@
     if (e.ctrlKey) key = 'ctrl+' + key;
     if (e.metaKey) key = 'meta+' + key;
     if (e.altKey) key = 'alt+' + key;
+    const path = event.composedPath();
     this.reporting.reportInteraction('shortcut-triggered', {
       key,
-      from:
-        (event.path && event.path[0] && (event.path[0] as Element).nodeName) ??
-        'unknown',
+      from: (path && path[0] && (path[0] as Element).nodeName) ?? 'unknown',
     });
   }
 
@@ -523,17 +548,19 @@
     this.$.errorView.classList.add('show');
     const response = e.detail.response;
     const err: ErrorInfo = {
-      text: [response.status, response.statusText].join(' '),
+      text: [response?.status, response?.statusText].join(' '),
     };
-    if (response.status === 404) {
+    if (response?.status === 404) {
       err.emoji = '¯\\_(ツ)_/¯';
       this._lastError = err;
     } else {
       err.emoji = 'o_O';
-      response.text().then(text => {
-        err.moreInfo = text;
-        this._lastError = err;
-      });
+      if (response) {
+        response.text().then(text => {
+          err.moreInfo = text;
+          this._lastError = err;
+        });
+      }
     }
   }
 
@@ -592,6 +619,14 @@
     }
   }
 
+  _handleDialogChange(e: CustomEvent<DialogChangeEventDetail>) {
+    if (e.detail.canceled) {
+      this._footerHeaderAriaHidden = false;
+    } else if (e.detail.opened) {
+      this._footerHeaderAriaHidden = true;
+    }
+  }
+
   handleShowKeyboardShortcuts() {
     this.loadKeyboardShortcutsDialog = true;
     flush();
@@ -608,17 +643,26 @@
     ) as GrOverlay;
     if (!keyboardShortcuts) return;
     if (keyboardShortcuts.opened) {
-      keyboardShortcuts.close();
+      keyboardShortcuts.cancel();
       return;
     }
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
     keyboardShortcuts.open();
+    this._footerHeaderAriaHidden = true;
+    this._mainAriaHidden = true;
   }
 
   _handleKeyboardShortcutDialogClose() {
-    (this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay).close();
+    (this.shadowRoot!.querySelector(
+      '#keyboardShortcuts'
+    ) as GrOverlay).cancel();
+  }
+
+  onOverlayCanceled() {
+    this._footerHeaderAriaHidden = false;
+    this._mainAriaHidden = false;
   }
 
   _handleAccountDetailUpdate() {
diff --git a/polygerrit-ui/app/elements/gr-app-element_html.ts b/polygerrit-ui/app/elements/gr-app-element_html.ts
index c1258fb..81a9988 100644
--- a/polygerrit-ui/app/elements/gr-app-element_html.ts
+++ b/polygerrit-ui/app/elements/gr-app-element_html.ts
@@ -104,9 +104,10 @@
     on-show-keyboard-shortcuts="handleShowKeyboardShortcuts"
     mobile-search-hidden="[[!mobileSearch]]"
     login-url="[[_loginUrl]]"
+    aria-hidden="[[_footerHeaderAriaHidden]]"
   >
   </gr-main-header>
-  <main>
+  <main aria-hidden="[[_mainAriaHidden]]">
     <template is="dom-if" if="[[mobileSearch]]">
       <gr-smart-search
         id="search"
@@ -176,7 +177,7 @@
       <div class="errorMoreInfo">[[_lastError.moreInfo]]</div>
     </div>
   </main>
-  <footer r="contentinfo">
+  <footer r="contentinfo" aria-hidden="[[_footerHeaderAriaHidden]]">
     <div>
       Powered by
       <a href="https://www.gerritcodereview.com/" rel="noopener" target="_blank"
@@ -201,7 +202,11 @@
     </div>
   </footer>
   <template is="dom-if" if="[[loadKeyboardShortcutsDialog]]">
-    <gr-overlay id="keyboardShortcuts" with-backdrop="">
+    <gr-overlay
+      id="keyboardShortcuts"
+      with-backdrop=""
+      on-iron-overlay-canceled="onOverlayCanceled"
+    >
       <gr-keyboard-shortcuts-dialog
         on-close="_handleKeyboardShortcutDialogClose"
       ></gr-keyboard-shortcuts-dialog>
@@ -223,10 +228,8 @@
     id="errorManager"
     login-url="[[_loginUrl]]"
   ></gr-error-manager>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   <gr-router id="router"></gr-router>
   <gr-plugin-host id="plugins" config="[[_serverConfig]]"> </gr-plugin-host>
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
   <gr-external-style
     id="externalStyleForAll"
     name="app-theme"
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index fac5f45..85a6d49 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -22,159 +22,17 @@
  * expose these variables until plugins switch to direct import from polygerrit.
  */
 
-import {
-  getAccountDisplayName,
-  getDisplayName,
-  getGroupDisplayName,
-  getUserName,
-} from '../utils/display-name-util';
 import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation';
-import {GrAttributeHelper} from './plugins/gr-attribute-helper/gr-attribute-helper';
-import {GrDiffLine, GrDiffLineType} from './diff/gr-diff/gr-diff-line';
-import {GrDiffGroup, GrDiffGroupType} from './diff/gr-diff/gr-diff-group';
-import {GrDiffBuilder} from './diff/gr-diff-builder/gr-diff-builder';
-import {GrDiffBuilderSideBySide} from './diff/gr-diff-builder/gr-diff-builder-side-by-side';
-import {GrDiffBuilderImage} from './diff/gr-diff-builder/gr-diff-builder-image';
-import {GrDiffBuilderUnified} from './diff/gr-diff-builder/gr-diff-builder-unified';
-import {GrDiffBuilderBinary} from './diff/gr-diff-builder/gr-diff-builder-binary';
-import {GrChangeActionsInterface} from './shared/gr-js-api-interface/gr-change-actions-js-api';
-import {GrChangeReplyInterface} from './shared/gr-js-api-interface/gr-change-reply-js-api';
-import {GrEditConstants} from './edit/gr-edit-constants';
-import {
-  GrDomHooksManager,
-  GrDomHook,
-} from './plugins/gr-dom-hooks/gr-dom-hooks';
-import {GrEtagDecorator} from './shared/gr-rest-api-interface/gr-etag-decorator';
-import {GrThemeApi} from './plugins/gr-theme-api/gr-theme-api';
-import {
-  SiteBasedCache,
-  FetchPromisesCache,
-  GrRestApiHelper,
-} from './shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
-import {GrLinkTextParser} from './shared/gr-linked-text/link-text-parser';
-import {
-  getPluginEndpoints,
-  GrPluginEndpoints,
-} from './shared/gr-js-api-interface/gr-plugin-endpoints';
-import {GrReviewerUpdatesParser} from './shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {GrPopupInterface} from './plugins/gr-popup-interface/gr-popup-interface';
-import {GrCountStringFormatter} from './shared/gr-count-string-formatter/gr-count-string-formatter';
-import {
-  GrReviewerSuggestionsProvider,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES,
-} from '../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
-import {util} from '../scripts/util';
 import {page} from '../utils/page-wrapper-utils';
 import {appContext} from '../services/app-context';
-import {GrAdminApi} from './plugins/gr-admin-api/gr-admin-api';
-import {GrAnnotationActionsContext} from './shared/gr-js-api-interface/gr-annotation-actions-context';
-import {GrAnnotationActionsInterface} from './shared/gr-js-api-interface/gr-annotation-actions-js-api';
-import {GrChangeMetadataApi} from './plugins/gr-change-metadata-api/gr-change-metadata-api';
-import {GrEmailSuggestionsProvider} from '../scripts/gr-email-suggestions-provider/gr-email-suggestions-provider';
-import {GrGroupSuggestionsProvider} from '../scripts/gr-group-suggestions-provider/gr-group-suggestions-provider';
-import {GrEventHelper} from './plugins/gr-event-helper/gr-event-helper';
-import {GrPluginRestApi} from './shared/gr-js-api-interface/gr-plugin-rest-api';
-import {GrRepoApi} from './plugins/gr-repo-api/gr-repo-api';
-import {GrSettingsApi} from './plugins/gr-settings-api/gr-settings-api';
-import {GrStylesApi} from './plugins/gr-styles-api/gr-styles-api';
-import {
-  getPluginLoader,
-  PluginLoader,
-} from './shared/gr-js-api-interface/gr-plugin-loader';
 import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
-import {
-  getPluginNameFromUrl,
-  getRestAPI,
-  PLUGIN_LOADING_TIMEOUT_MS,
-  PRELOADED_PROTOCOL,
-  send,
-} from './shared/gr-js-api-interface/gr-api-utils';
-import {getBaseUrl} from '../utils/url-util';
 import {GerritNav} from './core/gr-navigation/gr-navigation';
-import {getRootElement} from '../scripts/rootElement';
-import {rangesEqual} from './diff/gr-diff/gr-diff-utils';
-import {RevisionInfo} from './shared/revision-info/revision-info';
-import {CoverageType} from '../types/types';
-import {_setHiddenScroll, getHiddenScroll} from '../scripts/hiddenscroll';
 
 export function initGlobalVariables() {
-  window.GrDisplayNameUtils = {
-    getUserName,
-    getDisplayName,
-    getAccountDisplayName,
-    getGroupDisplayName,
-  };
   window.GrAnnotation = GrAnnotation;
-  window.GrAttributeHelper = GrAttributeHelper;
-  window.GrDiffLine = GrDiffLine;
-  window.GrDiffLineType = GrDiffLineType;
-  window.GrDiffGroup = GrDiffGroup;
-  window.GrDiffGroupType = GrDiffGroupType;
-  window.GrDiffBuilder = GrDiffBuilder;
-  window.GrDiffBuilderSideBySide = GrDiffBuilderSideBySide;
-  window.GrDiffBuilderImage = GrDiffBuilderImage;
-  window.GrDiffBuilderUnified = GrDiffBuilderUnified;
-  window.GrDiffBuilderBinary = GrDiffBuilderBinary;
-  window.GrChangeActionsInterface = GrChangeActionsInterface;
-  window.GrChangeReplyInterface = GrChangeReplyInterface;
-  window.GrEditConstants = GrEditConstants;
-  window.GrDomHooksManager = GrDomHooksManager;
-  window.GrDomHook = GrDomHook;
-  window.GrEtagDecorator = GrEtagDecorator;
-  window.GrThemeApi = GrThemeApi;
-  window.SiteBasedCache = SiteBasedCache;
-  window.FetchPromisesCache = FetchPromisesCache;
-  window.GrRestApiHelper = GrRestApiHelper;
-  window.GrLinkTextParser = GrLinkTextParser;
-  window.GrPluginEndpoints = GrPluginEndpoints;
-  window.GrReviewerUpdatesParser = GrReviewerUpdatesParser;
-  window.GrPopupInterface = GrPopupInterface;
-  window.GrCountStringFormatter = GrCountStringFormatter;
-  window.GrReviewerSuggestionsProvider = GrReviewerSuggestionsProvider;
-  window.util = util;
   window.page = page;
-  window.Auth = appContext.authService;
-  window.EventEmitter = appContext.eventEmitter;
-  window.GrAdminApi = GrAdminApi;
-  window.GrAnnotationActionsContext = GrAnnotationActionsContext;
-  window.GrAnnotationActionsInterface = GrAnnotationActionsInterface;
-  window.GrChangeMetadataApi = GrChangeMetadataApi;
-  window.GrEmailSuggestionsProvider = GrEmailSuggestionsProvider;
-  window.GrGroupSuggestionsProvider = GrGroupSuggestionsProvider;
-  window.GrEventHelper = GrEventHelper;
-  window.GrPluginRestApi = GrPluginRestApi;
-  window.GrRepoApi = GrRepoApi;
-  window.GrSettingsApi = GrSettingsApi;
-  window.GrStylesApi = GrStylesApi;
-  window.PluginLoader = PluginLoader;
   window.GrPluginActionContext = GrPluginActionContext;
-
-  window._apiUtils = {
-    getPluginNameFromUrl,
-    send,
-    getRestAPI,
-    getBaseUrl,
-    PRELOADED_PROTOCOL,
-    PLUGIN_LOADING_TIMEOUT_MS,
-  };
-
   window.Gerrit = window.Gerrit || {};
   window.Gerrit.Nav = GerritNav;
-  window.Gerrit.getRootElement = getRootElement;
   window.Gerrit.Auth = appContext.authService;
-
-  window.Gerrit._pluginLoader = getPluginLoader();
-  // TODO: should define as a getter
-  window.Gerrit._endpoints = getPluginEndpoints();
-
-  // TODO(TS): seems not used, probably just remove
-  window.Gerrit.slotToContent = (slot: any) => slot;
-  window.Gerrit.rangesEqual = rangesEqual;
-  window.Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES = SUGGESTIONS_PROVIDERS_USERS_TYPES;
-  window.Gerrit.RevisionInfo = RevisionInfo;
-  window.Gerrit.CoverageType = CoverageType;
-  Object.defineProperty(window.Gerrit, 'hiddenscroll', {
-    get: getHiddenScroll,
-    set: _setHiddenScroll,
-  });
 }
diff --git a/polygerrit-ui/app/elements/gr-app-init.ts b/polygerrit-ui/app/elements/gr-app-init.ts
index 6d79ce1..ab38326 100644
--- a/polygerrit-ui/app/elements/gr-app-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-init.ts
@@ -22,17 +22,6 @@
 } from '../services/gr-reporting/gr-reporting_impl';
 import {appContext} from '../services/app-context';
 
-interface UninitializedPolymer {
-  lazyRegister: boolean;
-}
-
-if (!window.Polymer) {
-  // Without as... it violates internal google rules.
-  ((window.Polymer as unknown) as UninitializedPolymer) = {
-    lazyRegister: true,
-  };
-}
-
 initAppContext();
 initVisibilityReporter(appContext);
 initPerformanceReporter(appContext);
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index b05117f..2ae85f7 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -16,18 +16,19 @@
  */
 import {
   GenerateUrlParameters,
-  GerritView,
   GroupDetailView,
   RepoDetailView,
 } from './core/gr-navigation/gr-navigation';
 import {
+  BasePatchSetNum,
   DashboardId,
   GroupId,
   NumericChangeId,
-  PatchSetNum,
   RepoName,
+  RevisionPatchSetNum,
   UrlEncodedCommentId,
 } from '../types/common';
+import {GerritView} from '../services/router/router-model';
 
 export interface AppElement extends HTMLElement {
   params: AppElementParams | GenerateUrlParameters;
@@ -99,8 +100,8 @@
   project?: RepoName;
   commentId?: UrlEncodedCommentId;
   path?: string;
-  patchNum?: PatchSetNum;
-  basePatchNum?: PatchSetNum;
+  patchNum?: RevisionPatchSetNum;
+  basePatchNum?: BasePatchSetNum;
   lineNum: number;
   leftSide?: boolean;
   commentLink?: boolean;
@@ -110,8 +111,8 @@
   changeNum: NumericChangeId;
   project: RepoName;
   edit?: boolean;
-  patchNum?: PatchSetNum;
-  basePatchNum?: PatchSetNum;
+  patchNum?: RevisionPatchSetNum;
+  basePatchNum?: BasePatchSetNum;
   queryMap?: Map<string, string> | URLSearchParams;
 }
 
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index f19931f..2d3289d 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -36,8 +36,6 @@
 
 import {initGlobalVariables} from './gr-app-global-var-init';
 import './gr-app-element';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-app_html';
 import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
@@ -47,7 +45,7 @@
 installPolymerResin(safeTypesBridge);
 
 @customElement('gr-app')
-class GrApp extends GestureEventListeners(LegacyElementMixin(PolymerElement)) {
+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 4c5e965..8178c89 100644
--- a/polygerrit-ui/app/elements/gr-app_test.js
+++ b/polygerrit-ui/app/elements/gr-app_test.js
@@ -20,37 +20,29 @@
 import {appContext} from '../services/app-context.js';
 import {GerritNav} from './core/gr-navigation/gr-navigation.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {stubRestApi} from '../test/test-utils.js';
 
 const basicFixture = fixtureFromTemplate(html`<gr-app id="app"></gr-app>`);
 
 suite('gr-app tests', () => {
   let element;
+  let configStub;
 
   setup(done => {
     sinon.stub(appContext.reportingService, 'appStarted');
-    stub('gr-account-dropdown', {
-      _getTopContent: sinon.stub(),
-    });
-    stub('gr-router', {
-      start: sinon.stub(),
-    });
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve({}); },
-      getAccountCapabilities() { return Promise.resolve({}); },
-      getConfig() {
-        return Promise.resolve({
-          plugin: {},
-          auth: {
-            auth_type: undefined,
-          },
-        });
+    stub('gr-account-dropdown', '_getTopContent');
+    stub('gr-router', 'start');
+    stubRestApi('getAccount').returns(Promise.resolve({}));
+    stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
+    configStub = stubRestApi('getConfig').returns(Promise.resolve({
+      plugin: {},
+      auth: {
+        auth_type: undefined,
       },
-      getPreferences() { return Promise.resolve({my: []}); },
-      getDiffPreferences() { return Promise.resolve({}); },
-      getEditPreferences() { return Promise.resolve({}); },
-      getVersion() { return Promise.resolve(42); },
-      probePath() { return Promise.resolve(42); },
-    });
+    }));
+    stubRestApi('getPreferences').returns(Promise.resolve({my: []}));
+    stubRestApi('getVersion').returns(Promise.resolve(42));
+    stubRestApi('probePath').returns(Promise.resolve(42));
 
     element = basicFixture.instantiate();
     flush(done);
@@ -69,12 +61,11 @@
     sinon.assert.callOrder(appStartedStub, routerStartStub);
   });
 
-  test('passes config to gr-plugin-host', () => {
-    const config = appElement().$.restAPI.getConfig;
-    return config.lastCall.returnValue.then(config => {
+  test('passes config to gr-plugin-host', () =>
+    configStub.lastCall.returnValue.then(config => {
       assert.deepEqual(appElement().$.plugins.config, config);
-    });
-  });
+    })
+  );
 
   test('_paramsChanged sets search page', () => {
     appElement()._paramsChanged({base: {view: GerritNav.View.CHANGE}});
diff --git a/polygerrit-ui/app/elements/lit/gr-lit-element.ts b/polygerrit-ui/app/elements/lit/gr-lit-element.ts
new file mode 100644
index 0000000..9ebadd5
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/gr-lit-element.ts
@@ -0,0 +1,52 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the 'License');
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an 'AS IS' BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {LitElement} from 'lit-element';
+import {Observable, Subject} from 'rxjs';
+import {takeUntil} from 'rxjs/operators';
+
+/**
+ * Base class for Gerrit's lit-elements.
+ *
+ * Adds basic functionality that we want to have available in all Gerrit's
+ * components.
+ */
+export abstract class GrLitElement extends LitElement {
+  disconnected$ = new Subject();
+
+  /**
+   * Hooks up an element property with an observable. Apart from subscribing it
+   * makes sure that you are unsubscribed when the component is disconnected.
+   * And it requests a template check when a new value comes in.
+   *
+   * Should be called from connectedCallback() such that you will be
+   * re-subscribed when the component is re-connected.
+   *
+   * TODO: Maybe distinctUntilChanged should be applied to obs$?
+   */
+  subscribe<Key extends keyof this>(prop: Key, obs$: Observable<this[Key]>) {
+    obs$.pipe(takeUntil(this.disconnected$)).subscribe(value => {
+      const oldValue = this[prop];
+      this[prop] = value;
+      this.requestUpdate(prop, oldValue);
+    });
+  }
+
+  disconnectedCallback() {
+    this.disconnected$.next();
+    super.disconnectedCallback();
+  }
+}
diff --git a/polygerrit-ui/app/elements/lit/gr-lit-element_test.ts b/polygerrit-ui/app/elements/lit/gr-lit-element_test.ts
new file mode 100644
index 0000000..9b7b0e2
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/gr-lit-element_test.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma';
+import {html, customElement} from 'lit-element';
+import {GrLitElement} from './gr-lit-element';
+
+@customElement('test-gr-lit-element')
+export class TestGrLitElement extends GrLitElement {
+  render() {
+    return html`<span>test</span>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'test-gr-lit-element': GrLitElement;
+  }
+}
+
+suite('gr-lit-element test', () => {
+  test('is defined', () => {
+    const el = document.createElement('test-gr-lit-element');
+    assert.instanceOf(el, TestGrLitElement);
+  });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
index 1332118..7a91c68 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
@@ -14,34 +14,33 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import {PluginApi} from '../gr-plugin-types';
-
-/** Interface for menu link */
-export interface MenuLink {
-  text: string;
-  url: string;
-  capability: string | null;
-}
+import {EventType, PluginApi} from '../../../api/plugin';
+import {AdminPluginApi, MenuLink} from '../../../api/admin';
+import {appContext} from '../../../services/app-context';
 
 /**
  * GrAdminApi class.
  *
  * Defines common methods to register / retrieve menu links.
  */
-export class GrAdminApi {
+export class GrAdminApi implements AdminPluginApi {
   // TODO(TS): maybe define as enum if its a limited set
   private menuLinks: MenuLink[] = [];
 
+  private readonly reporting = appContext.reportingService;
+
   constructor(private readonly plugin: PluginApi) {
-    this.plugin.on('admin-menu-links', this);
+    this.reporting.trackApi(this.plugin, 'admin', 'constructor');
+    this.plugin.on(EventType.ADMIN_MENU_LINKS, this);
   }
 
   addMenuLink(text: string, url: string, capability?: string) {
+    this.reporting.trackApi(this.plugin, 'admin', 'addMenuLink');
     this.menuLinks.push({text, url, capability: capability || null});
   }
 
   getMenuLinks(): MenuLink[] {
+    this.reporting.trackApi(this.plugin, 'admin', 'getMenuLinks');
     return this.menuLinks.slice(0);
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
index 6fc7a17..ab2ce6a 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
@@ -14,12 +14,21 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
+import {PluginApi} from '../../../api/plugin';
+import {appContext} from '../../../services/app-context';
 
-export class GrAttributeHelper {
+export class GrAttributeHelper implements AttributeHelperPluginApi {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   private readonly _promises = new Map<string, Promise<any>>();
 
-  // TOOD(TS): Change any to something more like HTMLElement.
-  constructor(public element: any) {}
+  private readonly reporting = appContext.reportingService;
+
+  // TODO(TS): Change any to something more like HTMLElement.
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  constructor(readonly plugin: PluginApi, public element: any) {
+    this.reporting.trackApi(this.plugin, 'attribute', 'constructor');
+  }
 
   _getChangedEventName(name: string): string {
     return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() + '-changed';
@@ -32,6 +41,7 @@
     return this.element[name] !== undefined;
   }
 
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   _reportValue(callback: (value: any) => void, value: any) {
     try {
       callback(value);
@@ -46,7 +56,9 @@
    * @param name Property name.
    * @return Unbind function.
    */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   bind(name: string, callback: (value: any) => void) {
+    this.reporting.trackApi(this.plugin, 'attribute', 'bind');
     const attributeChangedEventName = this._getChangedEventName(name);
     const changedHandler = (e: CustomEvent) =>
       this._reportValue(callback, e.detail.value);
@@ -67,10 +79,12 @@
    * to be initialized if it isn't defined.
    */
   get(name: string): Promise<unknown> {
+    this.reporting.trackApi(this.plugin, 'attribute', 'get');
     if (this._elementHasProperty(name)) {
       return Promise.resolve(this.element[name]);
     }
     if (!this._promises.has(name)) {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
       let resolve: (value: any) => void;
       const promise = new Promise(r => (resolve = r));
       const unbind = this.bind(name, value => {
@@ -85,7 +99,9 @@
   /**
    * Sets value and dispatches event to force notify.
    */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   set(name: string, value: any) {
+    this.reporting.trackApi(this.plugin, 'attribute', 'set');
     this.element[name] = value;
     this.element.dispatchEvent(
       new CustomEvent(this._getChangedEventName(name), {detail: {value}})
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
index ea7bdc3..2d83012 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
@@ -17,10 +17,10 @@
 
 import '../../../test/common-test-setup-karma.js';
 import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {GrAttributeHelper} from './gr-attribute-helper.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
 Polymer({
-  is: 'gr-attrubute-helper-some-element',
+  is: 'gr-attribute-helper-some-element',
   properties: {
     fooBar: {
       type: Object,
@@ -29,15 +29,20 @@
   },
 });
 
-const basicFixture = fixtureFromElement('gr-attrubute-helper-some-element');
+const basicFixture = fixtureFromElement('gr-attribute-helper-some-element');
+
+const pluginApi = _testOnly_initGerritPluginApi();
 
 suite('gr-attribute-helper tests', () => {
   let element;
   let instance;
 
   setup(() => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
     element = basicFixture.instantiate();
-    instance = new GrAttributeHelper(element);
+    instance = plugin.attributeHelper(element);
   });
 
   test('resolved on value change from undefined', () => {
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
deleted file mode 100644
index 322d32e..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
+++ /dev/null
@@ -1,42 +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 {HookApi, PluginApi} from '../gr-plugin-types';
-
-export class GrChangeMetadataApi {
-  private _hook: HookApi | null;
-
-  public plugin: PluginApi;
-
-  constructor(plugin: PluginApi) {
-    this.plugin = plugin;
-    this._hook = null;
-  }
-
-  _createHook() {
-    this._hook = this.plugin.hook('change-metadata-item');
-  }
-
-  onLabelsChanged(callback: (value: unknown) => void) {
-    if (!this._hook) {
-      this._createHook();
-    }
-    this._hook!.onAttached((element: Element) =>
-      this.plugin.attributeHelper(element).bind('labels', callback)
-    );
-    return this;
-  }
-}
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
new file mode 100644
index 0000000..39d3c8b
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Settings
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {PluginApi} from '../../../api/plugin';
+import {
+  ChecksApiConfig,
+  ChecksProvider,
+  ChecksPluginApi,
+} from '../../../api/checks';
+import {appContext} from '../../../services/app-context';
+
+const DEFAULT_CONFIG: ChecksApiConfig = {
+  fetchPollingIntervalSeconds: 60,
+};
+
+enum State {
+  NOT_REGISTERED,
+  REGISTERED,
+}
+
+/**
+ * Plugin API for checks.
+ *
+ * This object is created/returned to plugins that want to provide check data.
+ * Plugins normally just call register() once at startup and then wait for
+ * fetch() being called on the provider interface.
+ */
+export class GrChecksApi implements ChecksPluginApi {
+  private state = State.NOT_REGISTERED;
+
+  private readonly checksService = appContext.checksService;
+
+  private readonly reporting = appContext.reportingService;
+
+  constructor(readonly plugin: PluginApi) {
+    this.reporting.trackApi(this.plugin, 'checks', 'constructor');
+  }
+
+  announceUpdate() {
+    this.reporting.trackApi(this.plugin, 'checks', 'announceUpdate');
+    this.checksService.reload(this.plugin.getPluginName());
+  }
+
+  register(provider: ChecksProvider, config?: ChecksApiConfig): void {
+    this.reporting.trackApi(this.plugin, 'checks', 'register');
+    if (this.state === State.REGISTERED)
+      throw new Error('Only one provider can be registered per plugin.');
+    this.state = State.REGISTERED;
+    this.checksService.register(
+      this.plugin.getPluginName(),
+      provider,
+      config ?? DEFAULT_CONFIG
+    );
+  }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
new file mode 100644
index 0000000..e1ec158
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
+import {PluginApi} from '../../../api/plugin';
+import {ChecksPluginApi} from '../../../api/checks';
+
+const gerritPluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-settings-api tests', () => {
+  let checksApi: ChecksPluginApi | undefined;
+
+  setup(() => {
+    let pluginApi: PluginApi | undefined = undefined;
+    gerritPluginApi.install(
+      p => {
+        pluginApi = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    getPluginLoader().loadPlugins([]);
+    assert.isOk(pluginApi);
+    checksApi = pluginApi!.checks();
+  });
+
+  teardown(() => {
+    checksApi = undefined;
+  });
+
+  test('exists', () => {
+    assert.isOk(checksApi);
+  });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
index dd76be4..3e8f0a4 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
@@ -15,16 +15,17 @@
  * limitations under the License.
  */
 import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {HookApi, HookCallback, PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {HookApi, HookCallback} from '../../../api/hook';
 
 export class GrDomHooksManager {
-  private _hooks: Record<string, GrDomHook>;
+  private hooks: Record<string, GrDomHook>;
 
-  private _plugin: PluginApi;
+  private plugin: PluginApi;
 
   constructor(plugin: PluginApi) {
-    this._plugin = plugin;
-    this._hooks = {};
+    this.plugin = plugin;
+    this.hooks = {};
   }
 
   _getHookName(endpointName: string, moduleName?: string) {
@@ -35,37 +36,36 @@
       // TODO: this still can not prevent if plugin has invalid char
       // other than uppercase, but is the first step
       // https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
-      const pluginName: string =
-        this._plugin.getPluginName() || 'unknown_plugin';
+      const pluginName: string = this.plugin.getPluginName() || 'unknownplugin';
       return pluginName.toLowerCase() + '-autogenerated-' + endpointName;
     }
   }
 
   getDomHook(endpointName: string, moduleName?: string) {
     const hookName = this._getHookName(endpointName, moduleName);
-    if (!this._hooks[hookName]) {
-      this._hooks[hookName] = new GrDomHook(hookName, moduleName);
+    if (!this.hooks[hookName]) {
+      this.hooks[hookName] = new GrDomHook(hookName, moduleName);
     }
-    return this._hooks[hookName];
+    return this.hooks[hookName];
   }
 }
 
 export class GrDomHook implements HookApi {
-  private _instances: HTMLElement[] = [];
+  private instances: HTMLElement[] = [];
 
-  private _attachCallbacks: HookCallback[] = [];
+  private attachCallbacks: HookCallback[] = [];
 
-  private _detachCallbacks: HookCallback[] = [];
+  private detachCallbacks: HookCallback[] = [];
 
-  private _moduleName: string;
+  private moduleName: string;
 
-  private _lastAttachedPromise: Promise<HTMLElement> | null = null;
+  private lastAttachedPromise: Promise<HTMLElement> | null = null;
 
   constructor(hookName: string, moduleName?: string) {
     if (moduleName) {
-      this._moduleName = moduleName;
+      this.moduleName = moduleName;
     } else {
-      this._moduleName = hookName;
+      this.moduleName = hookName;
       this._createPlaceholder(hookName);
     }
   }
@@ -88,16 +88,16 @@
   }
 
   handleInstanceDetached(instance: HTMLElement) {
-    const index = this._instances.indexOf(instance);
+    const index = this.instances.indexOf(instance);
     if (index !== -1) {
-      this._instances.splice(index, 1);
+      this.instances.splice(index, 1);
     }
-    this._detachCallbacks.forEach(callback => callback(instance));
+    this.detachCallbacks.forEach(callback => callback(instance));
   }
 
   handleInstanceAttached(instance: HTMLElement) {
-    this._instances.push(instance);
-    this._attachCallbacks.forEach(callback => callback(instance));
+    this.instances.push(instance);
+    this.attachCallbacks.forEach(callback => callback(instance));
   }
 
   /**
@@ -105,32 +105,32 @@
    * Returns a Promise, that's resolved when attachment is done.
    */
   getLastAttached(): Promise<HTMLElement> {
-    if (this._instances.length) {
-      return Promise.resolve(this._instances.slice(-1)[0]);
+    if (this.instances.length) {
+      return Promise.resolve(this.instances.slice(-1)[0]);
     }
-    if (!this._lastAttachedPromise) {
+    if (!this.lastAttachedPromise) {
       let resolve: HookCallback;
       const promise = new Promise<HTMLElement>(r => {
         resolve = r;
-        this._attachCallbacks.push(resolve);
+        this.attachCallbacks.push(resolve);
       });
-      this._lastAttachedPromise = promise.then((element: HTMLElement) => {
-        this._lastAttachedPromise = null;
-        const index = this._attachCallbacks.indexOf(resolve);
+      this.lastAttachedPromise = promise.then((element: HTMLElement) => {
+        this.lastAttachedPromise = null;
+        const index = this.attachCallbacks.indexOf(resolve);
         if (index !== -1) {
-          this._attachCallbacks.splice(index, 1);
+          this.attachCallbacks.splice(index, 1);
         }
         return element;
       });
     }
-    return this._lastAttachedPromise;
+    return this.lastAttachedPromise;
   }
 
   /**
    * Get all DOM hook elements.
    */
   getAllAttached() {
-    return this._instances;
+    return this.instances;
   }
 
   /**
@@ -138,7 +138,7 @@
    * is attached.
    */
   onAttached(callback: HookCallback) {
-    this._attachCallbacks.push(callback);
+    this.attachCallbacks.push(callback);
     return this;
   }
 
@@ -148,7 +148,7 @@
    *
    */
   onDetached(callback: HookCallback) {
-    this._detachCallbacks.push(callback);
+    this.detachCallbacks.push(callback);
     return this;
   }
 
@@ -156,6 +156,6 @@
    * Name of DOM hook element that will be installed into the endpoint.
    */
   getModuleName() {
-    return this._moduleName;
+    return this.moduleName;
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
index 49223b9..883f2a6 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
@@ -45,7 +45,7 @@
     });
 
     test('getModuleName()', () => {
-      const hookName = Object.keys(instance._hooks).pop();
+      const hookName = Object.keys(instance.hooks).pop();
       assert.equal(hookName, 'testplugin-autogenerated-foo-bar');
       assert.equal(hook.getModuleName(), 'testplugin-autogenerated-foo-bar');
     });
@@ -57,7 +57,7 @@
     });
 
     test('getModuleName()', () => {
-      const hookName = Object.keys(instance._hooks).pop();
+      const hookName = Object.keys(instance.hooks).pop();
       assert.equal(hookName, 'foo-bar my-el');
       assert.equal(hook.getModuleName(), 'my-el');
     });
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
index 3a83729..0e8cbcb 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -14,9 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-endpoint-decorator_html';
 import {
@@ -25,14 +22,13 @@
 } from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {customElement, property} from '@polymer/decorators';
-import {HookApi, PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {HookApi} from '../../../api/hook';
 
 const INIT_PROPERTIES_TIMEOUT_MS = 10000;
 
 @customElement('gr-endpoint-decorator')
-class GrEndpointDecorator extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrEndpointDecorator extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -54,12 +50,12 @@
   _endpointCallBack: (info: ModuleInfo) => void = () => {};
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     for (const [el, domHook] of this._domHooks) {
       domHook.handleInstanceDetached(el);
     }
     getPluginEndpoints().onDetachedEndpoint(this.name, this._endpointCallBack);
+    super.disconnectedCallback();
   }
 
   _initDecoration(
@@ -71,7 +67,9 @@
     return this._initProperties(
       el,
       plugin,
-      this.getContentChildren().find(el => el.nodeName !== 'GR-ENDPOINT-PARAM')
+      // The direct children are slotted into <slot>, so this is identical to
+      // this.shadowRoot.querySelector('slot').assignedElements()[0].
+      this.firstElementChild
     ).then(el => {
       const slotEl = slot
         ? this.querySelector(`gr-endpoint-slot[name=${slot}]`)
@@ -85,9 +83,16 @@
     });
   }
 
+  // As of March 2021 the only known plugin that replaces an endpoint instead
+  // of decorating it is codemirror_editor.
   _initReplacement(name: string, plugin: PluginApi): Promise<HTMLElement> {
-    this.getContentChildNodes()
+    // The direct children are slotted into <slot>, so they are identical to
+    // this.shadowRoot.querySelector('slot').assignedElements().
+    const directChildren = [...this.childNodes];
+    const shadowChildren = [...(this.shadowRoot?.childNodes ?? [])];
+    [...directChildren, ...shadowChildren]
       .filter(node => node.nodeName !== 'GR-ENDPOINT-PARAM')
+      .filter(node => node.nodeName !== 'SLOT')
       .forEach(node => (node as ChildNode).remove());
     const el = document.createElement(name);
     return this._initProperties(el, plugin).then((el: HTMLElement) =>
@@ -102,13 +107,16 @@
   _initProperties(
     htmlEl: HTMLElement,
     plugin: PluginApi,
-    content?: HTMLElement
+    content?: Element | null
   ) {
     const el = htmlEl as HTMLElement & {
       plugin?: PluginApi;
-      content?: HTMLElement;
+      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.
     if (content) {
       el.content = content;
     }
@@ -125,12 +133,13 @@
           )
         );
     });
-    // TODO(TS): Should be a number, but TS thinks that is must be some weird
-    // NodeJS.Timeout object.
-    let timeoutId: any;
+    let timeoutId: number;
     const timeout = new Promise(
       () =>
-        (timeoutId = setTimeout(() => {
+        // specify window here so that TS pulls the correct setTimeout method
+        // if window is not specified, then the function is pulled from node
+        // and the return type is NodeJS.Timeout object
+        (timeoutId = window.setTimeout(() => {
           console.warn(
             'Timeout waiting for endpoint properties initialization: ' +
               `plugin ${plugin.getPluginName()}, endpoint ${this.name}`
@@ -183,7 +192,6 @@
     if (this.name) {
       getPluginLoader()
         .awaitPluginsLoaded()
-        .then(() => getPluginEndpoints().getAndImportPlugins(this.name))
         .then(() =>
           getPluginEndpoints()
             .getDetails(this.name)
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 c48258d..8138ff0 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
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import '../../../test/common-test-setup-karma.js';
 import './gr-endpoint-decorator.js';
 import '../gr-endpoint-param/gr-endpoint-param.js';
@@ -22,7 +21,6 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {resetPlugins} from '../../../test/test-utils.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
@@ -56,10 +54,8 @@
   setup(done => {
     resetPlugins();
     container = basicFixture.instantiate();
-    sinon.stub(getPluginEndpoints(), 'importUrl')
-        .callsFake( url => Promise.resolve());
     pluginApi.install(p => plugin = p, '0.1',
-        'http://some/plugin/url.html');
+        'http://some/plugin/url.js');
     // Decoration
     decorationHook = plugin.registerCustomComponent('first', 'some-module');
     decorationHookWithSlot = plugin.registerCustomComponent(
@@ -83,9 +79,6 @@
     const endpoints =
         Array.from(container.querySelectorAll('gr-endpoint-decorator'));
     assert.equal(endpoints.length, 3);
-    assert.isTrue(getPluginEndpoints().importUrl.calledWith(
-        new URL('http://some/plugin/url.html')
-    ));
   });
 
   test('decoration', () => {
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
index 9e9f348..ee89c86 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
 
@@ -26,9 +24,7 @@
 }
 
 @customElement('gr-endpoint-param')
-export class GrEndpointParam extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrEndpointParam extends PolymerElement {
   @property({type: String, reflectToAttribute: true})
   name = '';
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
index 5a4d2ae..0c6dd4d 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
@@ -14,27 +14,25 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {
+  EventHelperPluginApi,
+  UnsubscribeCallback,
+} from '../../../api/event-helper';
+import {PluginApi} from '../../../api/plugin';
+import {appContext} from '../../../services/app-context';
 
-export interface ListenOptions {
-  event?: string;
-  capture?: boolean;
-}
+export class GrEventHelper implements EventHelperPluginApi {
+  private readonly reporting = appContext.reportingService;
 
-export class GrEventHelper {
-  constructor(readonly element: HTMLElement) {}
-
-  /**
-   * Add a callback to arbitrary event.
-   * The callback may return false to prevent event bubbling.
-   */
-  on(event: string, callback: (event: Event) => boolean) {
-    return this._listen(this.element, callback, {event});
+  constructor(readonly plugin: PluginApi, readonly element: HTMLElement) {
+    this.reporting.trackApi(this.plugin, 'event', 'constructor');
   }
 
   /**
    * Alias for @see onClick
    */
   onTap(callback: (event: Event) => boolean) {
+    this.reporting.trackApi(this.plugin, 'event', 'onTap');
     return this.onClick(callback);
   }
 
@@ -43,34 +41,14 @@
    * The callback may return false to prevent event bubbling.
    */
   onClick(callback: (event: Event) => boolean) {
+    this.reporting.trackApi(this.plugin, 'event', 'onClick');
     return this._listen(this.element, callback);
   }
 
-  /**
-   * Alias for @see captureClick
-   */
-  captureTap(callback: (event: Event) => boolean) {
-    this.captureClick(callback);
-  }
-
-  /**
-   * Add a callback to element click or touch ahead of normal flow.
-   * Callback is installed on parent during capture phase.
-   * https://www.w3.org/TR/DOM-Level-3-Events/#event-flow
-   * The callback may return false to cancel regular event listeners.
-   */
-  captureClick(callback: (event: Event) => boolean) {
-    const parent = this.element.parentElement!;
-    return this._listen(parent, callback, {capture: true});
-  }
-
   _listen(
     container: HTMLElement,
-    callback: (event: Event) => boolean,
-    options?: ListenOptions | null
-  ) {
-    const capture = options?.capture;
-    const event = options?.event || 'click';
+    callback: (event: Event) => boolean
+  ): UnsubscribeCallback {
     const handler = (e: Event) => {
       const path = e.composedPath();
       if (!path) return;
@@ -88,9 +66,8 @@
         }
       }
     };
-    container.addEventListener(event, handler, capture);
-    const unsubscribe = () =>
-      container.removeEventListener(event, handler, capture);
+    container.addEventListener('click', handler);
+    const unsubscribe = () => container.removeEventListener('click', handler);
     return unsubscribe;
   }
 }
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 25c0d43..6291e64 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
@@ -17,8 +17,8 @@
 
 import '../../../test/common-test-setup-karma.js';
 import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
-import {GrEventHelper} from './gr-event-helper.js';
 import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
 Polymer({
   is: 'gr-event-helper-some-element',
@@ -33,13 +33,18 @@
 
 const basicFixture = fixtureFromElement('gr-event-helper-some-element');
 
+const pluginApi = _testOnly_initGerritPluginApi();
+
 suite('gr-event-helper tests', () => {
   let element;
   let instance;
 
   setup(() => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
     element = basicFixture.instantiate();
-    instance = new GrEventHelper(element);
+    instance = plugin.eventHelper(element);
   });
 
   test('onTap()', done => {
@@ -66,47 +71,5 @@
     flush();
     assert.isFalse(tapStub.called);
   });
-
-  test('captureTap()', done => {
-    instance.captureTap(() => {
-      done();
-    });
-    MockInteractions.tap(element);
-  });
-
-  test('captureClick()', done => {
-    instance.captureClick(() => {
-      done();
-    });
-    MockInteractions.tap(element);
-  });
-
-  test('captureTap() cancels tap()', () => {
-    const tapStub = sinon.stub();
-    addListener(element.parentElement, 'tap', tapStub);
-    instance.captureTap(() => false);
-    MockInteractions.tap(element);
-    flush();
-    assert.isFalse(tapStub.called);
-  });
-
-  test('captureClick() cancels click()', () => {
-    const tapStub = sinon.stub();
-    element.addEventListener('click', tapStub);
-    instance.captureTap(() => false);
-    MockInteractions.tap(element);
-    flush();
-    assert.isFalse(tapStub.called);
-  });
-
-  test('on()', done => {
-    instance.on('foo', () => {
-      done();
-    });
-    element.dispatchEvent(
-        new CustomEvent('foo', {
-          composed: true, bubbles: true,
-        }));
-  });
 });
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
index 02786dd..007e4c2 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
@@ -14,10 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import {updateStyles} from '@polymer/polymer/lib/mixins/element-mixin';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-external-style_html';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
@@ -25,9 +22,7 @@
 import {customElement, property} from '@polymer/decorators';
 
 @customElement('gr-external-style')
-class GrExternalStyle extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrExternalStyle extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -57,19 +52,15 @@
   }
 
   _importAndApply() {
-    getPluginEndpoints()
-      .getAndImportPlugins(this.name)
-      .then(() => {
-        const moduleNames = getPluginEndpoints().getModules(this.name);
-        for (const name of moduleNames) {
-          this._applyStyle(name);
-        }
-      });
+    const moduleNames = getPluginEndpoints().getModules(this.name);
+    for (const name of moduleNames) {
+      this._applyStyle(name);
+    }
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._importAndApply();
   }
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
index ad30f48..a192f80 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
@@ -14,12 +14,10 @@
  * 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-external-style.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 
@@ -30,7 +28,7 @@
 );
 
 suite('gr-external-style integration tests', () => {
-  const TEST_URL = 'http://some.com/plugins/url.html';
+  const TEST_URL = 'http://some.com/plugins/url.js';
 
   let element;
   let plugin;
@@ -66,8 +64,6 @@
   };
 
   setup(() => {
-    sinon.stub(getPluginEndpoints(), 'importUrl')
-        .callsFake( url => Promise.resolve());
     sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
         .returns(Promise.resolve());
   });
@@ -78,27 +74,12 @@
         .forEach(style => style.remove());
   });
 
-  test('imports plugin-provided module', async () => {
-    lateRegister();
-    await new Promise(flush);
-    assert.isTrue(getPluginEndpoints().importUrl.calledWith(new URL(TEST_URL)));
-  });
-
   test('applies plugin-provided styles', async () => {
     lateRegister();
     await new Promise(flush);
     assert.isTrue(element._applyStyle.calledWith('some-module'));
   });
 
-  test('does not double import', async () => {
-    earlyRegister();
-    await new Promise(flush);
-    plugin.registerStyleModule('foo', 'some-module');
-    await new Promise(flush);
-    // since loaded, should not call again
-    assert.isFalse(getPluginEndpoints().importUrl.calledOnce);
-  });
-
   test('does not double apply', async () => {
     earlyRegister();
     await new Promise(flush);
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
index ed84406..ac493a2 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,28 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 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 GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrPluginHost extends PolymerElement {
   @property({type: Object, observer: '_configChanged'})
   config?: ServerInfo;
 
   _configChanged(config: ServerInfo) {
     const plugins = config.plugin;
-    const htmlPlugins = (plugins && plugins.html_resource_paths) || [];
-    const jsPlugins = this._handleMigrations(
-      (plugins && plugins.js_resource_paths) || [],
-      htmlPlugins
-    );
+    const jsPlugins = (plugins && plugins.js_resource_paths) || [];
     const shouldLoadTheme =
       !!config.default_theme &&
       !getPluginLoader().isPluginPreloaded('preloaded:gerrit-theme');
@@ -43,30 +34,9 @@
     const themeToLoad: string[] = shouldLoadTheme
       ? [config.default_theme!]
       : [];
-
-    // Theme should be loaded first if has one to have better UX
-    const pluginsPending = themeToLoad.concat(jsPlugins, htmlPlugins);
-
-    const pluginOpts: {[key: string]: {sync: boolean}} = {};
-
-    if (shouldLoadTheme) {
-      // config.default_theme is defined when shouldLoadTheme is true
-      // Theme needs to be loaded synchronous.
-      pluginOpts[config.default_theme!] = {sync: true};
-    }
-
-    getPluginLoader().loadPlugins(pluginsPending, pluginOpts);
-  }
-
-  /**
-   * Omit .js plugins that have .html counterparts.
-   * For example, if plugin provides foo.js and foo.html, skip foo.js.
-   */
-  _handleMigrations(jsPlugins: string[], htmlPlugins: string[]) {
-    return jsPlugins.filter(url => {
-      const counterpart = url.replace(/\.js$/, '.html');
-      return !htmlPlugins.includes(counterpart);
-    });
+    // Theme should be loaded first for better UX.
+    const pluginsPending = themeToLoad.concat(jsPlugins);
+    getPluginLoader().loadPlugins(pluginsPending);
   }
 }
 
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 7a99dc4..3d35aa4 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
@@ -34,29 +34,27 @@
     sinon.stub(getPluginLoader(), 'loadPlugins');
     element.config = {
       plugin: {
-        html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
-        js_resource_paths: ['plugins/42'],
+        js_resource_paths: ['plugins/42', 'plugins/foo/bar', 'plugins/baz'],
       },
     };
     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', () => {
     sinon.stub(getPluginLoader(), 'loadPlugins');
     element.config = {
-      default_theme: 'gerrit-theme.html',
+      default_theme: 'gerrit-theme.js',
       plugin: {
-        html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
-        js_resource_paths: ['plugins/42'],
+        js_resource_paths: ['plugins/42', 'plugins/foo/bar', 'plugins/baz'],
       },
     };
     assert.isTrue(getPluginLoader().loadPlugins.calledOnce);
     assert.isTrue(getPluginLoader().loadPlugins.calledWith([
-      'gerrit-theme.html', 'plugins/42', 'plugins/foo/bar', 'plugins/baz',
-    ], {'gerrit-theme.html': {sync: true}}));
+      'gerrit-theme.js', 'plugins/42', 'plugins/foo/bar', 'plugins/baz',
+    ]));
   });
 
   test('skip theme if preloaded', () => {
@@ -69,7 +67,7 @@
       plugin: {},
     };
     assert.isTrue(getPluginLoader().loadPlugins.calledOnce);
-    assert.isTrue(getPluginLoader().loadPlugins.calledWith([], {}));
+    assert.isTrue(getPluginLoader().loadPlugins.calledWith([]));
   });
 });
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
deleted file mode 100644
index 410da215..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {GrAttributeHelper} from './gr-attribute-helper/gr-attribute-helper';
-import {GrPluginRestApi} from '../shared/gr-js-api-interface/gr-plugin-rest-api';
-import {GrEventHelper} from './gr-event-helper/gr-event-helper';
-import {GrPopupInterface} from './gr-popup-interface/gr-popup-interface';
-import {ConfigInfo} from '../../types/common';
-
-interface GerritElementExtensions {
-  content?: HTMLElement & {hidden?: boolean};
-  change?: unknown;
-  revision?: unknown;
-  token?: string;
-  repoName?: string;
-  config?: ConfigInfo;
-}
-export type HookCallback = (el: HTMLElement & GerritElementExtensions) => void;
-
-export interface HookApi {
-  onAttached(callback: HookCallback): HookApi;
-  onDetached(callback: HookCallback): HookApi;
-  getAllAttached(): HTMLElement[];
-  getLastAttached(): Promise<HTMLElement>;
-  getModuleName(): string;
-  handleInstanceDetached(instance: HTMLElement): void;
-  handleInstanceAttached(instance: HTMLElement): void;
-}
-
-export enum TargetElement {
-  CHANGE_ACTIONS = 'changeactions',
-  REPLY_DIALOG = 'replydialog',
-}
-
-// Note: for new events, naming convention should be: `a-b`
-export enum EventType {
-  HISTORY = 'history',
-  LABEL_CHANGE = 'labelchange',
-  SHOW_CHANGE = 'showchange',
-  SUBMIT_CHANGE = 'submitchange',
-  SHOW_REVISION_ACTIONS = 'show-revision-actions',
-  COMMIT_MSG_EDIT = 'commitmsgedit',
-  COMMENT = 'comment',
-  REVERT = 'revert',
-  REVERT_SUBMISSION = 'revert_submission',
-  POST_REVERT = 'postrevert',
-  ANNOTATE_DIFF = 'annotatediff',
-  ADMIN_MENU_LINKS = 'admin-menu-links',
-  HIGHLIGHTJS_LOADED = 'highlightjs-loaded',
-}
-
-export interface RegisterOptions {
-  slot?: string;
-  replace: unknown;
-}
-
-export interface PanelInfo {
-  body: Element;
-  p: {[key: string]: any};
-  onUnload: () => void;
-}
-
-export interface SettingsInfo {
-  body: Element;
-  token?: string;
-  onUnload: () => void;
-  setTitle: () => void;
-  setWindowTitle: () => void;
-  show: () => void;
-}
-
-export interface PluginApi {
-  _url?: URL;
-  popup(): Promise<GrPopupInterface>;
-  popup(moduleName: string): Promise<GrPopupInterface>;
-  popup(moduleName?: string): Promise<GrPopupInterface | null>;
-  hook(endpointName: string, opt_options?: RegisterOptions): HookApi;
-  getPluginName(): string;
-  on(eventName: string, target: any): void;
-  attributeHelper(element: Element): GrAttributeHelper;
-  restApi(): GrPluginRestApi;
-  eventHelper(element: Node): GrEventHelper;
-  registerDynamicCustomComponent(
-    endpointName: string,
-    moduleName?: string,
-    options?: RegisterOptions
-  ): HookApi;
-}
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
index 7c6587a..af6a159 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
@@ -15,8 +15,6 @@
  * limitations under the License.
  */
 import '../../shared/gr-overlay/gr-overlay';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-plugin-popup_html';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
@@ -34,9 +32,7 @@
   };
 }
 @customElement('gr-plugin-popup')
-export class GrPluginPopup extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrPluginPopup extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
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.js
index f2d83e8..6ce77b1 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.js
@@ -25,10 +25,8 @@
 
   setup(() => {
     element = basicFixture.instantiate();
-    stub('gr-overlay', {
-      open: sinon.stub().returns(Promise.resolve()),
-      close: sinon.stub(),
-    });
+    stub('gr-overlay', 'open').callsFake(() => Promise.resolve());
+    stub('gr-overlay', 'close');
   });
 
   test('exists', () => {
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
index d45c263..13d18b5 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
@@ -17,7 +17,9 @@
 import './gr-plugin-popup';
 import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GrPluginPopup} from './gr-plugin-popup';
-import {PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {PopupPluginApi} from '../../../api/popup';
+import {appContext} from '../../../services/app-context';
 
 interface CustomPolymerPluginEl extends HTMLElement {
   plugin: PluginApi;
@@ -26,23 +28,27 @@
 /**
  * Plugin popup API.
  * Provides method for opening and closing popups from plugin.
- * opt_moduleName is a name of custom element that will be automatically
+ * optmoduleName is a name of custom element that will be automatically
  * inserted on popup opening.
  */
-export class GrPopupInterface {
-  private _openingPromise: Promise<GrPopupInterface> | null = null;
+export class GrPopupInterface implements PopupPluginApi {
+  private openingPromise: Promise<GrPopupInterface> | null = null;
 
-  private _popup: GrPluginPopup | null = null;
+  private popup: GrPluginPopup | null = null;
+
+  private readonly reporting = appContext.reportingService;
 
   constructor(
     readonly plugin: PluginApi,
-    private _moduleName: string | null = null
-  ) {}
+    private moduleName: string | null = null
+  ) {
+    this.reporting.trackApi(this.plugin, 'popup', 'constructor');
+  }
 
   _getElement() {
     // TODO(TS): maybe consider removing this if no one is using
     // anything other than native methods on the return
-    return (dom(this._popup) as unknown) as HTMLElement;
+    return (dom(this.popup) as unknown) as HTMLElement;
   }
 
   /**
@@ -50,35 +56,37 @@
    * Creates the popup if not previously created. Creates popup content element,
    * if it was provided with constructor.
    */
-  open(): Promise<GrPopupInterface> {
-    if (!this._openingPromise) {
-      this._openingPromise = this.plugin
+  open(): Promise<PopupPluginApi> {
+    this.reporting.trackApi(this.plugin, 'popup', 'open');
+    if (!this.openingPromise) {
+      this.openingPromise = this.plugin
         .hook('plugin-overlay')
         .getLastAttached()
         .then(hookEl => {
           const popup = document.createElement('gr-plugin-popup');
-          if (this._moduleName) {
+          if (this.moduleName) {
             const el = popup.appendChild(
-              document.createElement(this._moduleName) as CustomPolymerPluginEl
+              document.createElement(this.moduleName) as CustomPolymerPluginEl
             );
             el.plugin = this.plugin;
           }
-          this._popup = hookEl.appendChild(popup);
+          this.popup = hookEl.appendChild(popup);
           flush();
-          return this._popup.open().then(() => this);
+          return this.popup.open().then(() => this);
         });
     }
-    return this._openingPromise;
+    return this.openingPromise;
   }
 
   /**
    * Hides the popup.
    */
   close() {
-    if (!this._popup) {
+    this.reporting.trackApi(this.plugin, 'popup', 'close');
+    if (!this.popup) {
       return;
     }
-    this._popup.close();
-    this._openingPromise = null;
+    this.popup.close();
+    this.openingPromise = null;
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
index be8836b..8a7788f 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
@@ -21,7 +21,6 @@
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {createIronOverlayBackdropStyleEl} from '../../../test/test-utils.js';
 
 class GrUserTestPopupElement extends PolymerElement {
   static get is() { return 'gr-user-test-popup'; }
@@ -40,10 +39,8 @@
   let container;
   let instance;
   let plugin;
-  let ironOverlayBackdropStyleEl;
 
   setup(() => {
-    ironOverlayBackdropStyleEl = createIronOverlayBackdropStyleEl();
     pluginApi.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     container = containerFixture.instantiate();
@@ -54,10 +51,6 @@
     });
   });
 
-  teardown(() => {
-    ironOverlayBackdropStyleEl.remove();
-  });
-
   suite('manual', () => {
     setup(() => {
       instance = new GrPopupInterface(plugin);
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.ts b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.ts
deleted file mode 100644
index 6eee643..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_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="shared-styles">
-    :host {
-      display: block;
-      margin-bottom: var(--spacing-xxl);
-    }
-  </style>
-  <h3>[[title]]</h3>
-  <gr-button on-click="_handleClick">[[title]]</gr-button>
-`;
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
deleted file mode 100644
index 701a560..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
+++ /dev/null
@@ -1,69 +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 './gr-plugin-repo-command';
-import {ConfigInfo} from '../../../types/common';
-import {HookApi, PluginApi} from '../gr-plugin-types';
-
-type RepoCommandCallback = (repo?: string, config?: ConfigInfo) => boolean;
-
-/**
- * Parameters provided on repo-command endpoint
- */
-export interface GrRepoCommandEndpointEl extends HTMLElement {
-  repoName: string;
-  config: ConfigInfo;
-}
-
-export class GrRepoApi {
-  private _hook?: HookApi;
-
-  constructor(readonly plugin: PluginApi) {}
-
-  // TODO(TS): should mark as public since used in gr-change-metadata-api
-  _createHook(title: string) {
-    return this.plugin.hook('repo-command').onAttached(element => {
-      const pluginCommand = document.createElement('gr-plugin-repo-command');
-      pluginCommand.title = title;
-      element.appendChild(pluginCommand);
-    });
-  }
-
-  createCommand(title: string, callback: RepoCommandCallback) {
-    if (this._hook) {
-      console.warn('Already set up.');
-      return this._hook;
-    }
-    this._hook = this._createHook(title);
-    this._hook.onAttached(element => {
-      if (callback(element.repoName, element.config) === false) {
-        element.hidden = true;
-      }
-    });
-    return this;
-  }
-
-  onTap(callback: (event: Event) => boolean) {
-    if (!this._hook) {
-      console.warn('Call createCommand first.');
-      return this;
-    }
-    this._hook.onAttached(element => {
-      this.plugin.eventHelper(element).on('command-tap', callback);
-    });
-    return this;
-  }
-}
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.js
deleted file mode 100644
index 9e24fda..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.js
+++ /dev/null
@@ -1,73 +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-endpoint-decorator/gr-endpoint-decorator.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-endpoint-decorator name="repo-command">
-    </gr-endpoint-decorator>
-`);
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-repo-api tests', () => {
-  let repoApi;
-
-  setup(() => {
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    getPluginLoader().loadPlugins([]);
-    repoApi = plugin.project();
-  });
-
-  teardown(() => {
-    repoApi = null;
-  });
-
-  test('exists', () => {
-    assert.isOk(repoApi);
-  });
-
-  test('works', done => {
-    const attachedStub = sinon.stub();
-    const tapStub = sinon.stub();
-    repoApi
-        .createCommand('foo', attachedStub)
-        .onTap(tapStub);
-    const element = basicFixture.instantiate();
-    flush(() => {
-      assert.isTrue(attachedStub.called);
-      const pluginCommand = element.shadowRoot
-          .querySelector('gr-plugin-repo-command');
-      assert.isOk(pluginCommand);
-      const btn = pluginCommand.shadowRoot
-          .querySelector('gr-button');
-      assert.isOk(btn);
-      assert.equal(btn.textContent, 'foo');
-      assert.isFalse(tapStub.called);
-      MockInteractions.tap(btn);
-      assert.isTrue(tapStub.called);
-      done();
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts
deleted file mode 100644
index c7f1ecd..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Settings
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../settings/gr-settings-view/gr-settings-item';
-import '../../settings/gr-settings-view/gr-settings-menu-item';
-import {PluginApi} from '../gr-plugin-types';
-
-export class GrSettingsApi {
-  private _token: string;
-
-  private _title = '(no title)';
-
-  private _moduleName?: string;
-
-  constructor(readonly plugin: PluginApi) {
-    // Generate default screen URL token, specific to plugin, and unique(ish).
-    this._token = plugin.getPluginName() + Math.random().toString(36).substr(5);
-  }
-
-  title(newTitle: string) {
-    this._title = newTitle;
-    return this;
-  }
-
-  token(newToken: string) {
-    this._token = newToken;
-    return this;
-  }
-
-  module(newModuleName: string) {
-    this._moduleName = newModuleName;
-    return this;
-  }
-
-  build() {
-    if (!this._moduleName) {
-      throw new Error('Settings screen custom element not defined!');
-    }
-    const token = `x/${this.plugin.getPluginName()}/${this._token}`;
-    this.plugin.hook('settings-menu-item').onAttached(el => {
-      const menuItem = document.createElement('gr-settings-menu-item');
-      menuItem.title = this._title;
-      menuItem.setAttribute('href', `#${token}`);
-      el.appendChild(menuItem);
-    });
-    const moduleName = this._moduleName;
-    return this.plugin.hook('settings-screen').onAttached(el => {
-      const item = document.createElement('gr-settings-item');
-      item.title = this._title;
-      item.anchor = token;
-      item.appendChild(document.createElement(moduleName));
-      el.appendChild(item);
-    });
-  }
-}
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.js b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.js
deleted file mode 100644
index 893f3e4..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_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-endpoint-decorator/gr-endpoint-decorator.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-endpoint-decorator name="settings-menu-item">
-    </gr-endpoint-decorator>
-    <gr-endpoint-decorator name="settings-screen">
-    </gr-endpoint-decorator>
-`);
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-settings-api tests', () => {
-  let settingsApi;
-
-  setup(() => {
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    getPluginLoader().loadPlugins([]);
-    settingsApi = plugin.settings();
-  });
-
-  teardown(() => {
-    settingsApi = null;
-  });
-
-  test('exists', () => {
-    assert.isOk(settingsApi);
-  });
-
-  test('works', done => {
-    settingsApi
-        .title('foo')
-        .token('bar')
-        .module('some-settings-screen')
-        .build();
-    const element = basicFixture.instantiate();
-    flush(() => {
-      const [menuItemEl, itemEl] = element;
-      const menuItem = menuItemEl.shadowRoot
-          .querySelector('gr-settings-menu-item');
-      assert.isOk(menuItem);
-      assert.equal(menuItem.title, 'foo');
-      assert.equal(menuItem.href, '#x/testplugin/bar');
-      const item = itemEl.shadowRoot
-          .querySelector('gr-settings-item');
-      assert.isOk(item);
-      assert.equal(item.title, 'foo');
-      assert.equal(item.anchor, 'x/testplugin/bar');
-      done();
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts
deleted file mode 100644
index 419c8db..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts
+++ /dev/null
@@ -1,94 +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.
- */
-
-/**
- * @fileoverview We should consider dropping support for this API:
- *
- * 1. we need to try avoid using `innerHTML` for xss concerns
- * 2. we have css variables which are more recommended way to custom styling
- */
-
-/**
- * // import { useShadow } from '@polymer/polymer/lib/utils/settings';
- * TODO(TS): polymer/lib/utils/settings.d.ts is not exporting useShadow
- * while the js is, to avoid the error, re-define it here
- */
-const useShadow = !window.ShadyDOM || !window.ShadyDOM.inUse;
-
-let styleObjectCount = 0;
-
-interface PgElement extends Element {
-  __pg_js_api_style_tags: {
-    [className: string]: boolean;
-  };
-}
-
-export class GrStyleObject {
-  private className = '';
-
-  constructor(private readonly rulesStr: string) {
-    this.className = `__pg_js_api_class_${styleObjectCount}`;
-    styleObjectCount++;
-  }
-
-  /**
-   * Creates a new unique CSS class and injects it in a root node of the element
-   * if it hasn't been added yet. A root node is an document or is the
-   * associated shadowRoot. This class can be added to any element with the same
-   * root node.
-   *
-   */
-  getClassName(element: Element) {
-    let rootNodeEl = useShadow ? element.getRootNode() : document.body;
-    if (rootNodeEl === document) {
-      rootNodeEl = document.head;
-    }
-    // TODO(TS): type casting to have correct interface
-    // maybe move this __pg_xxx to attribute
-    const rootNode: PgElement = rootNodeEl as PgElement;
-    if (!rootNode.__pg_js_api_style_tags) {
-      rootNode.__pg_js_api_style_tags = {};
-    }
-    if (!rootNode.__pg_js_api_style_tags[this.className]) {
-      const styleTag = document.createElement('style');
-      styleTag.innerHTML = `.${this.className} { ${this.rulesStr} }`;
-      rootNode.appendChild(styleTag);
-      rootNode.__pg_js_api_style_tags[this.className] = true;
-    }
-    return this.className;
-  }
-
-  /**
-   * Apply shared style to the element.
-   *
-   */
-  apply(element: Element) {
-    element.classList.add(this.getClassName(element));
-  }
-}
-
-/**
- * TODO(TS): move to util
- */
-export class GrStylesApi {
-  /**
-   * Creates a new GrStyleObject with specified style properties.
-   */
-  css(ruleStr: string) {
-    return new GrStyleObject(ruleStr);
-  }
-}
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
deleted file mode 100644
index c41b551..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
+++ /dev/null
@@ -1,185 +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 '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-class GrStyleTestElement extends PolymerElement {
-  static get is() { return 'gr-style-test-element'; }
-
-  static get template() {
-    return html`<div id="wrapper"></div>`;
-  }
-}
-
-customElements.define(GrStyleTestElement.is, GrStyleTestElement);
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-styles-api tests', () => {
-  let stylesApi;
-
-  setup(() => {
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    getPluginLoader().loadPlugins([]);
-    stylesApi = plugin.styles();
-  });
-
-  teardown(() => {
-    stylesApi = null;
-  });
-
-  test('exists', () => {
-    assert.isOk(stylesApi);
-  });
-
-  test('css', () => {
-    const styleObject = stylesApi.css('background: red');
-    assert.isDefined(styleObject);
-  });
-
-  suite('GrStyleObject tests', () => {
-    let stylesApi;
-    let displayInlineStyle;
-    let displayNoneStyle;
-    let elementsToRemove;
-
-    setup(() => {
-      let plugin;
-      pluginApi.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      getPluginLoader().loadPlugins([]);
-      stylesApi = plugin.styles();
-      displayInlineStyle = stylesApi.css('display: inline');
-      displayNoneStyle = stylesApi.css('display: none');
-      elementsToRemove = [];
-    });
-
-    teardown(() => {
-      displayInlineStyle = null;
-      displayNoneStyle = null;
-      stylesApi = null;
-      elementsToRemove.forEach(element => {
-        element.remove();
-      });
-      elementsToRemove = null;
-      sinon.restore();
-    });
-
-    function createNestedElements(parentElement) {
-      /* parentElement
-      *  |--- element1
-      *  |--- element2
-      *       |--- element3
-      **/
-      const element1 = document.createElement('div');
-      const element2 = document.createElement('div');
-      const element3 = document.createElement('div');
-      parentElement.appendChild(element1);
-      parentElement.appendChild(element2);
-      element2.appendChild(element3);
-
-      if (parentElement === document.body) {
-        elementsToRemove.push(element1);
-        elementsToRemove.push(element2);
-      }
-
-      return [element1, element2, element3];
-    }
-
-    test('getClassName  - body level elements', () => {
-      const bodyLevelElements = createNestedElements(document.body);
-
-      testGetClassName(bodyLevelElements);
-    });
-
-    test('getClassName  - elements inside polymer element', () => {
-      const polymerElement = document.createElement('gr-style-test-element');
-      document.body.appendChild(polymerElement);
-      elementsToRemove.push(polymerElement);
-      const contentElements = createNestedElements(polymerElement.$.wrapper);
-
-      testGetClassName(contentElements);
-    });
-
-    function testGetClassName(elements) {
-      assertAllElementsHaveDefaultStyle(elements);
-
-      const className1 = displayInlineStyle.getClassName(elements[0]);
-      const className2 = displayNoneStyle.getClassName(elements[1]);
-      const className3 = displayInlineStyle.getClassName(elements[2]);
-
-      assert.notEqual(className2, className1);
-      assert.equal(className3, className1);
-
-      assertAllElementsHaveDefaultStyle(elements);
-
-      elements[0].classList.add(className1);
-      elements[1].classList.add(className2);
-      elements[2].classList.add(className1);
-
-      assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
-    }
-
-    test('apply - body level elements', () => {
-      const bodyLevelElements = createNestedElements(document.body);
-
-      testApply(bodyLevelElements);
-    });
-
-    test('apply - elements inside polymer element', () => {
-      const polymerElement = document.createElement('gr-style-test-element');
-      document.body.appendChild(polymerElement);
-      elementsToRemove.push(polymerElement);
-      const contentElements = createNestedElements(polymerElement.$.wrapper);
-
-      testApply(contentElements);
-    });
-
-    function testApply(elements) {
-      assertAllElementsHaveDefaultStyle(elements);
-      displayInlineStyle.apply(elements[0]);
-      displayNoneStyle.apply(elements[1]);
-      displayInlineStyle.apply(elements[2]);
-      assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
-    }
-
-    function assertAllElementsHaveDefaultStyle(elements) {
-      for (const element of elements) {
-        assert.equal(getComputedStyle(element).getPropertyValue('display'),
-            'block');
-      }
-    }
-
-    function assertDisplayPropertyValues(elements, expectedDisplayValues) {
-      for (const key in elements) {
-        if (elements.hasOwnProperty(key)) {
-          assert.equal(
-              getComputedStyle(elements[key]).getPropertyValue('display'),
-              expectedDisplayValues[key]);
-        }
-      }
-    }
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
deleted file mode 100644
index 821e4bf..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
+++ /dev/null
@@ -1,37 +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 './gr-custom-plugin-header';
-import {GrCustomPluginHeader} from './gr-custom-plugin-header';
-import {PluginApi} from '../gr-plugin-types';
-
-/**
- * Defines api for theme, can be used to set header logo and title.
- */
-export class GrThemeApi {
-  constructor(private readonly plugin: PluginApi) {}
-
-  setHeaderLogoAndTitle(logoUrl: string, title: string) {
-    this.plugin.hook('header-title', {replace: true}).onAttached(element => {
-      const customHeader: GrCustomPluginHeader = document.createElement(
-        'gr-custom-plugin-header'
-      );
-      customHeader.logoUrl = logoUrl;
-      customHeader.title = title;
-      element.appendChild(customHeader);
-    });
-  }
-}
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.js
deleted file mode 100644
index 787ab0b..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.js
+++ /dev/null
@@ -1,73 +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-endpoint-decorator/gr-endpoint-decorator.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const headerTitleFixture = fixtureFromTemplate(html`
-<gr-endpoint-decorator name="header-title">
-      <span class="titleText"></span>
-    </gr-endpoint-decorator>
-`);
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-theme-api tests', () => {
-  let theme;
-
-  setup(() => {
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    theme = plugin.theme();
-  });
-
-  teardown(() => {
-    theme = null;
-  });
-
-  test('exists', () => {
-    assert.isOk(theme);
-  });
-
-  suite('header-title', () => {
-    let customHeader;
-
-    setup(() => {
-      headerTitleFixture.instantiate();
-      stub('gr-custom-plugin-header', {
-        /** @override */
-        ready() { customHeader = this; },
-      });
-      getPluginLoader().loadPlugins([]);
-    });
-
-    test('sets logo and title', done => {
-      theme.setHeaderLogoAndTitle('foo.jpg', 'bar');
-      flush(() => {
-        assert.isNotNull(customHeader);
-        assert.equal(customHeader.logoUrl, 'foo.jpg');
-        assert.equal(customHeader.title, 'bar');
-        done();
-      });
-    });
-  });
-});
-
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 8039bd8..d278d22 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -17,27 +17,18 @@
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-avatar/gr-avatar';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 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 {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {EditableAccountField} from '../../../constants/constants';
+import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
 
-export interface GrAccountInfo {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-account-info')
-export class GrAccountInfo extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAccountInfo extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -101,21 +92,23 @@
   @property({type: String})
   _avatarChangeUrl = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   loadData() {
     const promises = [];
 
     this._loading = true;
 
     promises.push(
-      this.$.restAPI.getConfig().then(config => {
+      this.restApiService.getConfig().then(config => {
         this._serverConfig = config;
       })
     );
 
-    promises.push(this.$.restAPI.invalidateAccountsDetailCache());
+    promises.push(this.restApiService.invalidateAccountsDetailCache());
 
     promises.push(
-      this.$.restAPI.getAccount().then(account => {
+      this.restApiService.getAccount().then(account => {
         if (!account) return;
         this._hasNameChange = false;
         this._hasUsernameChange = false;
@@ -130,7 +123,7 @@
     );
 
     promises.push(
-      this.$.restAPI.getAvatarChangeUrl().then(url => {
+      this.restApiService.getAvatarChangeUrl().then(url => {
         this._avatarChangeUrl = url || '';
       })
     );
@@ -157,12 +150,7 @@
         this._hasDisplayNameChange = false;
         this._hasStatusChange = false;
         this._saving = false;
-        this.dispatchEvent(
-          new CustomEvent('account-detail-update', {
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireEvent(this, 'account-detail-update');
       });
   }
 
@@ -170,7 +158,7 @@
     // Note that we are intentionally not acting on this._account.name being the
     // empty string (which is falsy).
     return this._hasNameChange && this.nameMutable && this._account?.name
-      ? this.$.restAPI.setAccountName(this._account.name)
+      ? this.restApiService.setAccountName(this._account.name)
       : Promise.resolve();
   }
 
@@ -178,20 +166,20 @@
     // Note that we are intentionally not acting on this._username being the
     // empty string (which is falsy).
     return this._hasUsernameChange && this.usernameMutable && this._username
-      ? this.$.restAPI.setAccountUsername(this._username)
+      ? this.restApiService.setAccountUsername(this._username)
       : Promise.resolve();
   }
 
   _maybeSetDisplayName() {
     return this._hasDisplayNameChange &&
       this._account?.display_name !== undefined
-      ? this.$.restAPI.setAccountDisplayName(this._account.display_name)
+      ? this.restApiService.setAccountDisplayName(this._account.display_name)
       : Promise.resolve();
   }
 
   _maybeSetStatus() {
     return this._hasStatusChange && this._account?.status !== undefined
-      ? this.$.restAPI.setAccountStatus(this._account.status)
+      ? this.restApiService.setAccountStatus(this._account.status)
       : Promise.resolve();
   }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
index d69a279..6ca484d 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
@@ -41,9 +41,7 @@
     <section class$="[[_hideAvatarChangeUrl(_avatarChangeUrl)]]">
       <span class="title"></span>
       <span class="value">
-        <a href$="[[_avatarChangeUrl]]">
-          Change avatar
-        </a>
+        <a href$="[[_avatarChangeUrl]]"> Change avatar </a>
       </span>
     </section>
     <section>
@@ -67,34 +65,40 @@
       <span class="title">Username</span>
       <span hidden$="[[usernameMutable]]" class="value">[[_username]]</span>
       <span hidden$="[[!usernameMutable]]" class="value">
-        <iron-input on-keydown="_handleKeydown" bind-value="{{_username}}">
+        <iron-input
+          on-keydown="_handleKeydown"
+          bind-value="{{_username}}"
+          id="usernameIronInput"
+        >
           <input
             is="iron-input"
             id="usernameInput"
             disabled="[[_saving]]"
             on-keydown="_handleKeydown"
-            bind-value="{{_username}}"
           />
         </iron-input>
       </span>
     </section>
     <section id="nameSection">
-      <span class="title">Full name</span>
+      <label class="title" for="nameInput">Full name</label>
       <span hidden$="[[nameMutable]]" class="value">[[_account.name]]</span>
       <span hidden$="[[!nameMutable]]" class="value">
-        <iron-input on-keydown="_handleKeydown" bind-value="{{_account.name}}">
+        <iron-input
+          on-keydown="_handleKeydown"
+          bind-value="{{_account.name}}"
+          id="nameIronInput"
+        >
           <input
             is="iron-input"
             id="nameInput"
             disabled="[[_saving]]"
             on-keydown="_handleKeydown"
-            bind-value="{{_account.name}}"
           />
         </iron-input>
       </span>
     </section>
     <section>
-      <span class="title">Display name</span>
+      <label class="title" for="displayNameInput">Display name</label>
       <span class="value">
         <iron-input
           on-keydown="_handleKeydown"
@@ -105,13 +109,12 @@
             id="displayNameInput"
             disabled="[[_saving]]"
             on-keydown="_handleKeydown"
-            bind-value="{{_account.display_name}}"
           />
         </iron-input>
       </span>
     </section>
     <section>
-      <span class="title">Status (e.g. "Vacation")</span>
+      <label class="title" for="statusInput">Status (e.g. "Vacation")</label>
       <span class="value">
         <iron-input
           on-keydown="_handleKeydown"
@@ -122,11 +125,9 @@
             id="statusInput"
             disabled="[[_saving]]"
             on-keydown="_handleKeydown"
-            bind-value="{{_account.status}}"
           />
         </iron-input>
       </span>
     </section>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
similarity index 66%
rename from polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js
rename to polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
index d359ad2..6aa7f7c 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
@@ -15,53 +15,63 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-account-info.js';
+import '../../../test/common-test-setup-karma';
+import './gr-account-info';
+import {SinonSpyMember, stubRestApi} from '../../../test/test-utils';
+import {GrAccountInfo} from './gr-account-info';
+import {AccountDetailInfo, ServerInfo} from '../../../types/common';
+import {
+  createAccountWithIdNameAndEmail,
+  createPreferences,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {IronInputElement} from '@polymer/iron-input';
+import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 
 const basicFixture = fixtureFromElement('gr-account-info');
 
 suite('gr-account-info tests', () => {
-  let element;
-  let account;
-  let config;
+  let element!: GrAccountInfo;
+  let account: AccountDetailInfo;
+  let config: ServerInfo;
 
-  function valueOf(title) {
-    const sections = element.root.querySelectorAll('section');
+  function queryIronInput(selector: string): IronInputElement {
+    const input = element.root?.querySelector<IronInputElement>(selector);
+    if (!input) assert.fail(`<iron-input> with id ${selector} not found.`);
+    return input;
+  }
+
+  function valueOf(title: string): Element {
+    const sections = element.root?.querySelectorAll('section') ?? [];
     let titleEl;
     for (let i = 0; i < sections.length; i++) {
       titleEl = sections[i].querySelector('.title');
-      if (titleEl.textContent === title) {
-        return sections[i].querySelector('.value');
+      if (titleEl?.textContent === title) {
+        const el = sections[i].querySelector('.value');
+        if (el) return el;
       }
     }
+    assert.fail(`element with title ${title} not found`);
   }
 
-  setup(done => {
-    account = {
-      _account_id: 123,
-      name: 'user name',
-      email: 'user@email',
-      username: 'user username',
-      registered: '2000-01-01 00:00:00.000000000',
-    };
-    config = {auth: {editable_account_fields: []}};
+  setup(async () => {
+    account = createAccountWithIdNameAndEmail(123) as AccountDetailInfo;
+    config = createServerInfo();
 
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve(account); },
-      getConfig() { return Promise.resolve(config); },
-      getPreferences() {
-        return Promise.resolve({time_format: 'HHMM_12'});
-      },
-    });
+    stubRestApi('getAccount').returns(Promise.resolve(account));
+    stubRestApi('getConfig').returns(Promise.resolve(config));
+    stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
+
     element = basicFixture.instantiate();
-    // Allow the element to render.
-    element.loadData().then(() => { flush(done); });
+    await element.loadData();
+    await flush();
   });
 
   test('basic account info render', () => {
     assert.isFalse(element._loading);
 
-    assert.equal(valueOf('ID').textContent, account._account_id);
+    assert.equal(valueOf('ID').textContent, `${account._account_id}`);
     assert.equal(valueOf('Email').textContent, account.email);
     assert.equal(valueOf('Username').textContent, account.username);
   });
@@ -78,8 +88,9 @@
   });
 
   test('full name render (mutable)', () => {
-    element.set('_serverConfig',
-        {auth: {editable_account_fields: ['FULL_NAME']}});
+    element.set('_serverConfig', {
+      auth: {editable_account_fields: ['FULL_NAME']},
+    });
 
     const section = element.$.nameSection;
     const displaySpan = section.querySelectorAll('.value')[0];
@@ -87,7 +98,7 @@
 
     assert.isTrue(element.nameMutable);
     assert.isTrue(displaySpan.hasAttribute('hidden'));
-    assert.equal(element.$.nameInput.bindValue, account.name);
+    assert.equal(queryIronInput('#nameIronInput').bindValue, account.name);
     assert.isFalse(inputSpan.hasAttribute('hidden'));
   });
 
@@ -103,8 +114,9 @@
   });
 
   test('username render (mutable)', () => {
-    element.set('_serverConfig',
-        {auth: {editable_account_fields: ['USER_NAME']}});
+    element.set('_serverConfig', {
+      auth: {editable_account_fields: ['USER_NAME']},
+    });
     element.set('_account.username', '');
     element.set('_username', '');
 
@@ -114,31 +126,34 @@
 
     assert.isTrue(element.usernameMutable);
     assert.isTrue(displaySpan.hasAttribute('hidden'));
-    assert.equal(element.$.usernameInput.bindValue, account.username);
+    assert.equal(
+      queryIronInput('#usernameIronInput').bindValue,
+      account.username
+    );
     assert.isFalse(inputSpan.hasAttribute('hidden'));
   });
 
   suite('account info edit', () => {
-    let nameChangedSpy;
-    let usernameChangedSpy;
-    let statusChangedSpy;
-    let nameStub;
-    let usernameStub;
-    let statusStub;
+    let nameChangedSpy: SinonSpyMember<typeof element._nameChanged>;
+    let usernameChangedSpy: SinonSpyMember<typeof element._usernameChanged>;
+    let statusChangedSpy: SinonSpyMember<typeof element._statusChanged>;
+    let nameStub: SinonStubbedMember<RestApiService['setAccountName']>;
+    let usernameStub: SinonStubbedMember<RestApiService['setAccountUsername']>;
+    let statusStub: SinonStubbedMember<RestApiService['setAccountStatus']>;
 
     setup(() => {
       nameChangedSpy = sinon.spy(element, '_nameChanged');
       usernameChangedSpy = sinon.spy(element, '_usernameChanged');
       statusChangedSpy = sinon.spy(element, '_statusChanged');
-      element.set('_serverConfig',
-          {auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']}});
+      element.set('_serverConfig', {
+        auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']},
+      });
 
-      nameStub = sinon.stub(element.$.restAPI, 'setAccountName').callsFake(
-          name => Promise.resolve());
-      usernameStub = sinon.stub(element.$.restAPI, 'setAccountUsername')
-          .callsFake(username => Promise.resolve());
-      statusStub = sinon.stub(element.$.restAPI, 'setAccountStatus').callsFake(
-          status => Promise.resolve());
+      nameStub = stubRestApi('setAccountName').returns(Promise.resolve());
+      usernameStub = stubRestApi('setAccountUsername').returns(
+        Promise.resolve()
+      );
+      statusStub = stubRestApi('setAccountStatus').returns(Promise.resolve());
     });
 
     test('name', done => {
@@ -206,23 +221,21 @@
   });
 
   suite('edit name and status', () => {
-    let nameChangedSpy;
-    let statusChangedSpy;
-    let nameStub;
-    let statusStub;
+    let nameChangedSpy: SinonSpyMember<typeof element._nameChanged>;
+    let statusChangedSpy: SinonSpyMember<typeof element._statusChanged>;
+    let nameStub: SinonStubbedMember<RestApiService['setAccountName']>;
+    let statusStub: SinonStubbedMember<RestApiService['setAccountStatus']>;
 
     setup(() => {
       nameChangedSpy = sinon.spy(element, '_nameChanged');
       statusChangedSpy = sinon.spy(element, '_statusChanged');
-      element.set('_serverConfig',
-          {auth: {editable_account_fields: ['FULL_NAME']}});
+      element.set('_serverConfig', {
+        auth: {editable_account_fields: ['FULL_NAME']},
+      });
 
-      nameStub = sinon.stub(element.$.restAPI, 'setAccountName').callsFake(
-          name => Promise.resolve());
-      statusStub = sinon.stub(element.$.restAPI, 'setAccountStatus').callsFake(
-          status => Promise.resolve());
-      sinon.stub(element.$.restAPI, 'setAccountUsername').callsFake(
-          username => Promise.resolve());
+      nameStub = stubRestApi('setAccountName').returns(Promise.resolve());
+      statusStub = stubRestApi('setAccountStatus').returns(Promise.resolve());
+      stubRestApi('setAccountUsername').returns(Promise.resolve());
     });
 
     test('set name and status', done => {
@@ -253,16 +266,14 @@
   });
 
   suite('set status but read name', () => {
-    let statusChangedSpy;
-    let statusStub;
+    let statusChangedSpy: SinonSpyMember<typeof element._statusChanged>;
+    let statusStub: SinonStubbedMember<RestApiService['setAccountStatus']>;
 
     setup(() => {
       statusChangedSpy = sinon.spy(element, '_statusChanged');
-      element.set('_serverConfig',
-          {auth: {editable_account_fields: []}});
+      element.set('_serverConfig', {auth: {editable_account_fields: []}});
 
-      statusStub = sinon.stub(element.$.restAPI, 'setAccountStatus').callsFake(
-          status => Promise.resolve());
+      statusStub = stubRestApi('setAccountStatus').returns(Promise.resolve());
     });
 
     test('read full name but set status', done => {
@@ -318,4 +329,3 @@
     assert.equal(element._hideAvatarChangeUrl('https://example.com'), '');
   });
 });
-
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
index 5523be1..c1cff72 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
@@ -17,25 +17,15 @@
 
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-agreements-list_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {ContributorAgreementInfo} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
 
-export interface GrAgreementsList {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-agreements-list')
-export class GrAgreementsList extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAgreementsList extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -43,14 +33,16 @@
   @property({type: Array})
   _agreements?: ContributorAgreementInfo[];
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.loadData();
   }
 
   loadData() {
-    return this.$.restAPI.getAccountAgreements().then(agreements => {
+    return this.restApiService.getAccountAgreements().then(agreements => {
       this._agreements = agreements;
     });
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.ts b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.ts
index 194ca2b..1afac0d 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.ts
@@ -52,5 +52,4 @@
     </table>
     <a href$="[[getUrl()]]">New Contributor Agreement</a>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js
deleted file mode 100644
index 0c785aa..0000000
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js
+++ /dev/null
@@ -1,55 +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-agreements-list.js';
-
-const basicFixture = fixtureFromElement('gr-agreements-list');
-
-suite('gr-agreements-list tests', () => {
-  let element;
-  let agreements;
-
-  setup(done => {
-    agreements = [{
-      url: 'some url',
-      description: 'Agreements 1 description',
-      name: 'Agreements 1',
-    }];
-
-    stub('gr-rest-api-interface', {
-      getAccountAgreements() { return Promise.resolve(agreements); },
-    });
-
-    element = basicFixture.instantiate();
-
-    element.loadData().then(() => { flush(done); });
-  });
-
-  test('renders', () => {
-    const rows = element.root.querySelectorAll('tbody tr');
-
-    assert.equal(rows.length, 1);
-
-    const nameCells = Array.from(rows).map(row =>
-      row.querySelectorAll('td')[0].textContent.trim()
-    );
-
-    assert.equal(nameCells[0], 'Agreements 1');
-  });
-});
-
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
new file mode 100644
index 0000000..f08976f
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts
@@ -0,0 +1,56 @@
+/**
+ * @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-agreements-list';
+import {stubRestApi} from '../../../test/test-utils';
+import {GrAgreementsList} from './gr-agreements-list';
+import {ContributorAgreementInfo} from '../../../types/common';
+
+const basicFixture = fixtureFromElement('gr-agreements-list');
+
+suite('gr-agreements-list tests', () => {
+  let element: GrAgreementsList;
+
+  setup(done => {
+    const agreements: ContributorAgreementInfo[] = [
+      {
+        url: 'some url',
+        description: 'Agreements 1 description',
+        name: 'Agreements 1',
+      },
+    ];
+
+    stubRestApi('getAccountAgreements').returns(Promise.resolve(agreements));
+
+    element = basicFixture.instantiate();
+
+    element.loadData().then(() => {
+      flush(done);
+    });
+  });
+
+  test('renders', () => {
+    const rows = element.root?.querySelectorAll('tbody tr') ?? [];
+    assert.equal(rows.length, 1);
+
+    const nameCells = Array.from(rows).map(row =>
+      row.querySelectorAll('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 c0d6126..d13b2a9 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -16,31 +16,49 @@
  */
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import '../../../styles/gr-form-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-table-editor_html';
 import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
-import {customElement, property} from '@polymer/decorators';
+import {customElement, property, observe} from '@polymer/decorators';
+import {ServerInfo} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
 
 @customElement('gr-change-table-editor')
-class GrChangeTableEditor extends ChangeTableMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrChangeTableEditor extends ChangeTableMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
 
   @property({type: Array, notify: true})
-  displayedColumns?: string[];
+  displayedColumns: string[] = [];
 
   @property({type: Boolean, notify: true})
   showNumber?: boolean;
 
+  @property({type: Object})
+  serverConfig?: ServerInfo;
+
+  @property({type: Array})
+  defaultColumns: string[] = [];
+
+  flagsService = appContext.flagsService;
+
+  @observe('serverConfig')
+  _configChanged(config: ServerInfo) {
+    this.defaultColumns = this.getEnabledColumns(
+      this.columnNames,
+      config,
+      this.flagsService.enabledExperiments
+    );
+    if (!this.displayedColumns) return;
+    this.displayedColumns = this.displayedColumns.filter(column =>
+      this.isColumnEnabled(column, config, this.flagsService.enabledExperiments)
+    );
+  }
+
   /**
    * Get the list of enabled column names from whichever checkboxes are
    * checked (excluding the number checkbox).
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
index 1233cf1..a05ec73 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
@@ -48,12 +48,13 @@
       </thead>
       <tbody>
         <tr>
-          <td>Number</td>
+          <td><label for="numberCheckbox">Number</label></td>
           <td
             class="checkboxContainer"
             on-click="_handleCheckboxContainerClick"
           >
             <input
+              id="numberCheckbox"
               type="checkbox"
               name="number"
               on-click="_handleNumberCheckboxClick"
@@ -61,14 +62,15 @@
             />
           </td>
         </tr>
-        <template is="dom-repeat" items="[[columnNames]]">
+        <template is="dom-repeat" items="[[defaultColumns]]">
           <tr>
-            <td>[[item]]</td>
+            <td><label for$="[[item]]">[[item]]</label></td>
             <td
               class="checkboxContainer"
               on-click="_handleCheckboxContainerClick"
             >
               <input
+                id$="[[item]]"
                 type="checkbox"
                 name="[[item]]"
                 on-click="_handleTargetClick"
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js
deleted file mode 100644
index 42085ff..0000000
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js
+++ /dev/null
@@ -1,151 +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-table-editor.js';
-
-const basicFixture = fixtureFromElement('gr-change-table-editor');
-
-suite('gr-change-table-editor tests', () => {
-  let element;
-  let columns;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-
-    columns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Updated',
-    ];
-
-    element.set('displayedColumns', columns);
-    element.showNumber = false;
-    flush();
-  });
-
-  test('renders', () => {
-    const rows = element.shadowRoot
-        .querySelector('tbody').querySelectorAll('tr');
-    let tds;
-
-    // The `+ 1` is for the number column, which isn't included in the change
-    // table behavior's list.
-    assert.equal(rows.length, element.columnNames.length + 1);
-    for (let i = 0; i < columns.length; i++) {
-      tds = rows[i + 1].querySelectorAll('td');
-      assert.equal(tds[0].textContent, columns[i]);
-    }
-  });
-
-  test('hide item', () => {
-    const checkbox = element.shadowRoot
-        .querySelector('table tr:nth-child(2) input');
-    const isChecked = checkbox.checked;
-    const displayedLength = element.displayedColumns.length;
-    assert.isTrue(isChecked);
-
-    MockInteractions.tap(checkbox);
-    flush();
-
-    assert.equal(element.displayedColumns.length, displayedLength - 1);
-  });
-
-  test('show item', () => {
-    element.set('displayedColumns', [
-      'Status',
-      'Owner',
-      'Assignee',
-      'Repo',
-      'Branch',
-      'Updated',
-    ]);
-    flush();
-    const checkbox = element.shadowRoot
-        .querySelector('table tr:nth-child(2) input');
-    const isChecked = checkbox.checked;
-    const displayedLength = element.displayedColumns.length;
-    assert.isFalse(isChecked);
-    assert.equal(element.shadowRoot
-        .querySelector('table').style.display, '');
-
-    MockInteractions.tap(checkbox);
-    flush();
-
-    assert.equal(element.displayedColumns.length,
-        displayedLength + 1);
-  });
-
-  test('_getDisplayedColumns', () => {
-    assert.deepEqual(element._getDisplayedColumns(), columns);
-    MockInteractions.tap(
-        element.shadowRoot
-            .querySelector('.checkboxContainer input[name=Assignee]'));
-    assert.deepEqual(element._getDisplayedColumns(),
-        columns.filter(c => c !== 'Assignee'));
-  });
-
-  test('_handleCheckboxContainerClick relays taps to checkboxes', () => {
-    sinon.stub(element, '_handleNumberCheckboxClick');
-    sinon.stub(element, '_handleTargetClick');
-
-    MockInteractions.tap(
-        element.shadowRoot
-            .querySelector('table tr:first-of-type .checkboxContainer'));
-    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
-    assert.isFalse(element._handleTargetClick.called);
-
-    MockInteractions.tap(
-        element.shadowRoot
-            .querySelector('table tr:last-of-type .checkboxContainer'));
-    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
-    assert.isTrue(element._handleTargetClick.calledOnce);
-  });
-
-  test('_handleNumberCheckboxClick', () => {
-    sinon.spy(element, '_handleNumberCheckboxClick');
-
-    MockInteractions
-        .tap(element.shadowRoot
-            .querySelector('.checkboxContainer input[name=number]'));
-    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
-    assert.isTrue(element.showNumber);
-
-    MockInteractions
-        .tap(element.shadowRoot
-            .querySelector('.checkboxContainer input[name=number]'));
-    assert.isTrue(element._handleNumberCheckboxClick.calledTwice);
-    assert.isFalse(element.showNumber);
-  });
-
-  test('_handleTargetClick', () => {
-    sinon.spy(element, '_handleTargetClick');
-    assert.include(element.displayedColumns, 'Assignee');
-    MockInteractions
-        .tap(element.shadowRoot
-            .querySelector('.checkboxContainer input[name=Assignee]'));
-    assert.isTrue(element._handleTargetClick.calledOnce);
-    assert.notInclude(element.displayedColumns, 'Assignee');
-  });
-});
-
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
new file mode 100644
index 0000000..4f61972
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -0,0 +1,182 @@
+/**
+ * @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 * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import '../../../test/common-test-setup-karma';
+import './gr-change-table-editor';
+import {GrChangeTableEditor} from './gr-change-table-editor';
+import {queryAndAssert} from '../../../test/test-utils';
+import {createServerInfo} from '../../../test/test-data-generators';
+import {ServerInfo} from '../../../types/common';
+
+const basicFixture = fixtureFromElement('gr-change-table-editor');
+
+suite('gr-change-table-editor tests', () => {
+  let element: GrChangeTableEditor;
+  let columns: string[];
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+
+    columns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Repo',
+      'Branch',
+      'Updated',
+    ];
+
+    element.set('displayedColumns', columns);
+    element.showNumber = false;
+    element.serverConfig = createServerInfo();
+    await flush();
+  });
+
+  test('renders', () => {
+    const rows = queryAndAssert(element, 'tbody').querySelectorAll('tr');
+    let tds;
+
+    // The `+ 1` is for the number column, which isn't included in the change
+    // table behavior's list.
+    assert.equal(rows.length, element.defaultColumns.length + 1);
+    for (let i = 0; i < element.defaultColumns.length; i++) {
+      tds = rows[i + 1].querySelectorAll('td');
+      assert.equal(tds[0].textContent, element.defaultColumns[i]);
+    }
+  });
+
+  test('disabled experiments are hidden', () => {
+    assert.isFalse(element.displayedColumns.includes('Assignee'));
+    element.set('displayedColumns', columns);
+    const config: ServerInfo = {...createServerInfo()};
+    config.change.enable_assignee = true;
+    element.serverConfig = config;
+    flush();
+    assert.isTrue(element.displayedColumns.includes('Assignee'));
+  });
+
+  test('hide item', () => {
+    const checkbox = queryAndAssert<HTMLInputElement>(
+      element,
+      'table tr:nth-child(2) input'
+    );
+    const isChecked = checkbox.checked;
+    const displayedLength = element.displayedColumns.length;
+    assert.isTrue(isChecked);
+
+    MockInteractions.tap(checkbox);
+    flush();
+
+    assert.equal(element.displayedColumns.length, displayedLength - 1);
+  });
+
+  test('show item', () => {
+    element.set('displayedColumns', [
+      'Status',
+      'Owner',
+      'Assignee',
+      'Repo',
+      'Branch',
+      'Updated',
+    ]);
+    // trigger computation of enabled displayed columns
+    element.serverConfig = createServerInfo();
+    flush();
+    const checkbox = queryAndAssert<HTMLInputElement>(
+      element,
+      'table tr:nth-child(2) input'
+    );
+    const isChecked = checkbox.checked;
+    const displayedLength = element.displayedColumns.length;
+    assert.isFalse(isChecked);
+    const table = queryAndAssert<HTMLTableElement>(element, 'table');
+    assert.equal(table.style.display, '');
+
+    MockInteractions.tap(checkbox);
+    flush();
+
+    assert.equal(element.displayedColumns.length, displayedLength + 1);
+  });
+
+  test('_getDisplayedColumns', () => {
+    const enabledColumns = columns.filter(column =>
+      element.isColumnEnabled(column, element.serverConfig!, [])
+    );
+    assert.deepEqual(element._getDisplayedColumns(), enabledColumns);
+    const input = queryAndAssert<HTMLInputElement>(
+      element,
+      '.checkboxContainer input[name=Subject]'
+    );
+    MockInteractions.tap(input);
+    assert.deepEqual(
+      element._getDisplayedColumns(),
+      enabledColumns.filter(c => c !== 'Subject')
+    );
+  });
+
+  test('_handleCheckboxContainerClick relays taps to checkboxes', () => {
+    const checkBoxClickStub = sinon.stub(element, '_handleNumberCheckboxClick');
+    const targetClickStub = sinon.stub(element, '_handleTargetClick');
+
+    const firstContainer = queryAndAssert(
+      element,
+      'table tr:first-of-type .checkboxContainer'
+    );
+    MockInteractions.tap(firstContainer);
+    assert.isTrue(checkBoxClickStub.calledOnce);
+    assert.isFalse(targetClickStub.called);
+
+    const lastContainer = queryAndAssert(
+      element,
+      'table tr:last-of-type .checkboxContainer'
+    );
+    MockInteractions.tap(lastContainer);
+    assert.isTrue(checkBoxClickStub.calledOnce);
+    assert.isTrue(targetClickStub.calledOnce);
+  });
+
+  test('_handleNumberCheckboxClick', () => {
+    const checkBoxClickSpy = sinon.spy(element, '_handleNumberCheckboxClick');
+
+    const numberInput = queryAndAssert(
+      element,
+      '.checkboxContainer input[name=number]'
+    );
+    MockInteractions.tap(numberInput);
+    assert.isTrue(checkBoxClickSpy.calledOnce);
+    assert.isTrue(element.showNumber);
+
+    MockInteractions.tap(numberInput);
+    assert.isTrue(checkBoxClickSpy.calledTwice);
+    assert.isFalse(element.showNumber);
+  });
+
+  test('_handleTargetClick', () => {
+    const targetClickSpy = sinon.spy(element, '_handleTargetClick');
+    assert.include(element.displayedColumns, 'Subject');
+    const subjectInput = queryAndAssert(
+      element,
+      '.checkboxContainer input[name=Subject]'
+    );
+    MockInteractions.tap(subjectInput);
+    assert.isTrue(targetClickSpy.calledOnce);
+    assert.notInclude(element.displayedColumns, 'Subject');
+  });
+});
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
index 28cd672..b0b7ab8 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
@@ -19,25 +19,17 @@
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-cla-view_html';
 import {getBaseUrl} from '../../../utils/url-util';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {customElement, property} from '@polymer/decorators';
 import {
   ServerInfo,
   GroupInfo,
   ContributorAgreementInfo,
 } from '../../../types/common';
-
-export interface GrClaView {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {fireAlert, fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -46,9 +38,7 @@
 }
 
 @customElement('gr-cla-view')
-export class GrClaView extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrClaView extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -74,30 +64,26 @@
   @property({type: String})
   _agreementsUrl?: string;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.loadData();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'New Contributor Agreement'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'New Contributor Agreement');
   }
 
   loadData() {
     const promises = [];
     promises.push(
-      this.$.restAPI.getConfig(true).then(config => {
+      this.restApiService.getConfig(true).then(config => {
         this._serverConfig = config;
       })
     );
 
     promises.push(
-      this.$.restAPI.getAccountGroups().then(groups => {
+      this.restApiService.getAccountGroups().then(groups => {
         if (!groups) return;
         this._groups = groups.sort((a, b) =>
           (a.name || '').localeCompare(b.name || '')
@@ -106,7 +92,7 @@
     );
 
     promises.push(
-      this.$.restAPI
+      this.restApiService
         .getAccountAgreements()
         .then((agreements: ContributorAgreementInfo[] | undefined) => {
           this._signedAgreements = agreements || [];
@@ -143,7 +129,7 @@
     this._createToast('Agreement saving...');
 
     const name = this._agreementName;
-    return this.$.restAPI.saveAccountAgreement({name}).then(res => {
+    return this.restApiService.saveAccountAgreement({name}).then(res => {
       let message = 'Agreement failed to be submitted, please try again';
       if (res.status === 200) {
         message = 'Agreement has been successfully submitted.';
@@ -156,13 +142,7 @@
   }
 
   _createToast(message: string) {
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {message},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireAlert(this, message);
   }
 
   _computeShowAgreementsClass(showAgreements: boolean) {
@@ -171,16 +151,14 @@
 
   _disableAgreements(
     item: ContributorAgreementInfo,
-    groups: GroupInfo[],
-    signedAgreements: ContributorAgreementInfo[]
+    groups?: GroupInfo[],
+    signedAgreements?: ContributorAgreementInfo[]
   ) {
     if (!groups) return false;
     for (const group of groups) {
       if (
-        (item &&
-          item.auto_verify_group &&
-          item.auto_verify_group.id === group.id) ||
-        signedAgreements.find(i => i.name === item.name)
+        item?.auto_verify_group?.id === group.id ||
+        signedAgreements?.find(i => i.name === item.name)
       ) {
         return true;
       }
@@ -191,7 +169,7 @@
   _hideAgreements(
     item: ContributorAgreementInfo,
     groups: GroupInfo[],
-    signedAgreements: ContributorAgreementInfo[]
+    signedAgreements?: ContributorAgreementInfo[]
   ) {
     return this._disableAgreements(item, groups, signedAgreements)
       ? ''
@@ -207,7 +185,7 @@
   // then hides the text box and submit button.
   _computeHideAgreementClass(
     name: string,
-    contributorAgreements: ContributorAgreementInfo[]
+    contributorAgreements?: ContributorAgreementInfo[]
   ) {
     if (!contributorAgreements) return '';
     return contributorAgreements.some(
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
index c461718..887382e 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
@@ -25,7 +25,7 @@
       margin-bottom: var(--spacing-m);
     }
     .agreementsUrl {
-      border: 1px solid #b0bdcc;
+      border: 1px solid var(--border-color);
       margin-bottom: var(--spacing-xl);
       margin-left: var(--spacing-xl);
       margin-right: var(--spacing-xl);
@@ -84,9 +84,7 @@
       >
         Agreement already submitted.
       </div>
-      <div class="agreementsUrl">
-        [[item.description]]
-      </div>
+      <div class="agreementsUrl">[[item.description]]</div>
     </template>
     <div
       id="claNewAgreement"
@@ -122,5 +120,4 @@
       </div>
     </div>
   </main>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js
deleted file mode 100644
index aeacea4..0000000
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js
+++ /dev/null
@@ -1,180 +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-cla-view.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-
-const basicFixture = fixtureFromElement('gr-cla-view');
-
-suite('gr-cla-view tests', () => {
-  let element;
-  const signedAgreements = [{
-    name: 'CLA',
-    description: 'Contributor License Agreement',
-    url: 'static/cla.html',
-  }];
-  const auth = {
-    name: 'Individual',
-    description: 'test-description',
-    url: 'static/cla_individual.html',
-    auto_verify_group: {
-      url: '#/admin/groups/uuid-e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-      options: {
-        visible_to_all: true,
-      },
-      group_id: 20,
-      owner: 'CLA Accepted - Individual',
-      owner_id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-      created_on: '2017-07-31 15:11:04.000000000',
-      id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-      name: 'CLA Accepted - Individual',
-    },
-  };
-
-  const auth2 = {
-    name: 'Individual2',
-    description: 'test-description2',
-    url: 'static/cla_individual2.html',
-    auto_verify_group: {
-      url: '#/admin/groups/uuid-bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
-      options: {},
-      group_id: 21,
-      owner: 'CLA Accepted - Individual2',
-      owner_id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
-      created_on: '2017-07-31 15:25:42.000000000',
-      id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
-      name: 'CLA Accepted - Individual2',
-    },
-  };
-
-  const auth3 = {
-    name: 'CLA',
-    description: 'Contributor License Agreement',
-    url: 'static/cla_individual.html',
-  };
-
-  const config = {
-    auth: {
-      use_contributor_agreements: true,
-      contributor_agreements: [
-        {
-          name: 'Individual',
-          description: 'test-description',
-          url: 'static/cla_individual.html',
-        },
-        {
-          name: 'CLA',
-          description: 'Contributor License Agreement',
-          url: 'static/cla.html',
-        }],
-    },
-  };
-  const config2 = {
-    auth: {
-      use_contributor_agreements: true,
-      contributor_agreements: [
-        {
-          name: 'Individual2',
-          description: 'test-description2',
-          url: 'static/cla_individual2.html',
-        },
-      ],
-    },
-  };
-  const groups = [{
-    options: {visible_to_all: true},
-    id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-    group_id: 3,
-    name: 'CLA Accepted - Individual',
-  },
-  ];
-
-  setup(done => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve(config); },
-      getAccountGroups() { return Promise.resolve(groups); },
-      getAccountAgreements() { return Promise.resolve(signedAgreements); },
-    });
-    element = basicFixture.instantiate();
-    element.loadData().then(() => { flush(done); });
-  });
-
-  test('renders as expected with signed agreement', () => {
-    const agreementSections = dom(element.root)
-        .querySelectorAll('.contributorAgreementButton');
-    const agreementSubmittedTexts = dom(element.root)
-        .querySelectorAll('.alreadySubmittedText');
-    assert.equal(agreementSections.length, 2);
-    assert.isFalse(agreementSections[0].querySelector('input').disabled);
-    assert.equal(getComputedStyle(agreementSubmittedTexts[0]).display,
-        'none');
-    assert.isTrue(agreementSections[1].querySelector('input').disabled);
-    assert.notEqual(getComputedStyle(agreementSubmittedTexts[1]).display,
-        'none');
-  });
-
-  test('_disableAgreements', () => {
-    // In the auto verify group and have not yet signed agreement
-    assert.isTrue(
-        element._disableAgreements(auth, groups, signedAgreements));
-    // Not in the auto verify group and have not yet signed agreement
-    assert.isFalse(
-        element._disableAgreements(auth2, groups, signedAgreements));
-    // Not in the auto verify group, have signed agreement
-    assert.isTrue(
-        element._disableAgreements(auth3, groups, signedAgreements));
-    // Make sure the undefined check works
-    assert.isFalse(
-        element._disableAgreements(auth, undefined, signedAgreements));
-  });
-
-  test('_hideAgreements', () => {
-    // Not in the auto verify group and have not yet signed agreement
-    assert.equal(
-        element._hideAgreements(auth, groups, signedAgreements), '');
-    // In the auto verify group
-    assert.equal(
-        element._hideAgreements(auth2, groups, signedAgreements), 'hide');
-    // Not in the auto verify group, have signed agreement
-    assert.equal(
-        element._hideAgreements(auth3, groups, signedAgreements), '');
-  });
-
-  test('_disableAgreementsText', () => {
-    assert.isFalse(element._disableAgreementsText('I AGREE'));
-    assert.isTrue(element._disableAgreementsText('I DO NOT AGREE'));
-  });
-
-  test('_computeHideAgreementClass', () => {
-    assert.equal(
-        element._computeHideAgreementClass(
-            auth.name, config.auth.contributor_agreements),
-        'hideAgreementsTextBox');
-    assert.isNotOk(
-        element._computeHideAgreementClass(
-            auth.name, config2.auth.contributor_agreements));
-  });
-
-  test('_getAgreementsUrl', () => {
-    assert.equal(element._getAgreementsUrl(
-        'http://test.org/test.html'), 'http://test.org/test.html');
-    assert.equal(element._getAgreementsUrl(
-        'test_cla.html'), '/test_cla.html');
-  });
-});
-
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
new file mode 100644
index 0000000..9f54cd1
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
@@ -0,0 +1,210 @@
+/**
+ * @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-cla-view';
+import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {GrClaView} from './gr-cla-view';
+import {
+  ContributorAgreementInfo,
+  GroupId,
+  GroupInfo,
+  GroupName,
+  ServerInfo,
+} from '../../../types/common';
+import {AuthType} from '../../../constants/constants';
+import {createServerInfo} from '../../../test/test-data-generators';
+
+const basicFixture = fixtureFromElement('gr-cla-view');
+
+suite('gr-cla-view tests', () => {
+  let element: GrClaView;
+  const signedAgreements = [
+    {
+      name: 'CLA',
+      description: 'Contributor License Agreement',
+      url: 'static/cla.html',
+    },
+  ];
+  const auth: ContributorAgreementInfo = {
+    name: 'Individual',
+    description: 'test-description',
+    url: 'static/cla_individual.html',
+    auto_verify_group: {
+      url: '#/admin/groups/uuid-e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+      options: {
+        visible_to_all: true,
+      },
+      group_id: '20',
+      owner: 'CLA Accepted - Individual',
+      owner_id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+      created_on: '2017-07-31 15:11:04.000000000',
+      id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0' as GroupId,
+      name: 'CLA Accepted - Individual' as GroupName,
+    },
+  };
+
+  const auth2: ContributorAgreementInfo = {
+    name: 'Individual2',
+    description: 'test-description2',
+    url: 'static/cla_individual2.html',
+    auto_verify_group: {
+      url: '#/admin/groups/uuid-bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
+      options: {
+        visible_to_all: false,
+      },
+      group_id: '21',
+      owner: 'CLA Accepted - Individual2',
+      owner_id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
+      created_on: '2017-07-31 15:25:42.000000000',
+      id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb' as GroupId,
+      name: 'CLA Accepted - Individual2' as GroupName,
+    },
+  };
+
+  const auth3: ContributorAgreementInfo = {
+    name: 'CLA',
+    description: 'Contributor License Agreement',
+    url: 'static/cla_individual.html',
+  };
+
+  const config: ServerInfo = {
+    ...createServerInfo(),
+    auth: {
+      auth_type: AuthType.HTTP,
+      editable_account_fields: [],
+      use_contributor_agreements: true,
+      contributor_agreements: [
+        {
+          name: 'Individual',
+          description: 'test-description',
+          url: 'static/cla_individual.html',
+        },
+        {
+          name: 'CLA',
+          description: 'Contributor License Agreement',
+          url: 'static/cla.html',
+        },
+      ],
+    },
+  };
+  const config2: ServerInfo = {
+    ...createServerInfo(),
+    auth: {
+      auth_type: AuthType.HTTP,
+      editable_account_fields: [],
+      use_contributor_agreements: true,
+      contributor_agreements: [
+        {
+          name: 'Individual2',
+          description: 'test-description2',
+          url: 'static/cla_individual2.html',
+        },
+      ],
+    },
+  };
+  const groups: GroupInfo[] = [
+    {
+      options: {visible_to_all: true},
+      id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0' as GroupId,
+      group_id: '3',
+      name: 'CLA Accepted - Individual' as GroupName,
+    },
+  ];
+
+  setup(done => {
+    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);
+    });
+  });
+
+  test('renders as expected with signed agreement', () => {
+    const agreementSections = queryAll(element, '.contributorAgreementButton');
+    const agreementSubmittedTexts = queryAll(element, '.alreadySubmittedText');
+    assert.equal(agreementSections.length, 2);
+    assert.isFalse(
+      queryAndAssert<HTMLInputElement>(agreementSections[0], 'input').disabled
+    );
+    assert.equal(getComputedStyle(agreementSubmittedTexts[0]).display, 'none');
+    assert.isTrue(
+      queryAndAssert<HTMLInputElement>(agreementSections[1], 'input').disabled
+    );
+    assert.notEqual(
+      getComputedStyle(agreementSubmittedTexts[1]).display,
+      'none'
+    );
+  });
+
+  test('_disableAgreements', () => {
+    // In the auto verify group and have not yet signed agreement
+    assert.isTrue(element._disableAgreements(auth, groups, signedAgreements));
+    // Not in the auto verify group and have not yet signed agreement
+    assert.isFalse(element._disableAgreements(auth2, groups, signedAgreements));
+    // Not in the auto verify group, have signed agreement
+    assert.isTrue(element._disableAgreements(auth3, groups, signedAgreements));
+    // Make sure the undefined check works
+    assert.isFalse(
+      element._disableAgreements(auth, undefined, signedAgreements)
+    );
+  });
+
+  test('_hideAgreements', () => {
+    // Not in the auto verify group and have not yet signed agreement
+    assert.equal(element._hideAgreements(auth, groups, signedAgreements), '');
+    // In the auto verify group
+    assert.equal(
+      element._hideAgreements(auth2, groups, signedAgreements),
+      'hide'
+    );
+    // Not in the auto verify group, have signed agreement
+    assert.equal(element._hideAgreements(auth3, groups, signedAgreements), '');
+  });
+
+  test('_disableAgreementsText', () => {
+    assert.isFalse(element._disableAgreementsText('I AGREE'));
+    assert.isTrue(element._disableAgreementsText('I DO NOT AGREE'));
+  });
+
+  test('_computeHideAgreementClass', () => {
+    assert.equal(
+      element._computeHideAgreementClass(
+        auth.name,
+        config.auth.contributor_agreements
+      ),
+      'hideAgreementsTextBox'
+    );
+    assert.isNotOk(
+      element._computeHideAgreementClass(
+        auth.name,
+        config2.auth.contributor_agreements
+      )
+    );
+  });
+
+  test('_getAgreementsUrl', () => {
+    assert.equal(
+      element._getAgreementsUrl('http://test.org/test.html'),
+      'http://test.org/test.html'
+    );
+    assert.equal(element._getAgreementsUrl('test_cla.html'), '/test_cla.html');
+  });
+});
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 72283d6..6be82e0 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
@@ -17,19 +17,15 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-edit-preferences_html';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {EditPreferencesInfo} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
 
 export interface GrEditPreferences {
   $: {
-    restAPI: RestApiService & Element;
     editSyntaxHighlighting: HTMLInputElement;
     showAutoCloseBrackets: HTMLInputElement;
     showIndentWithTabs: HTMLInputElement;
@@ -40,9 +36,7 @@
   };
 }
 @customElement('gr-edit-preferences')
-export class GrEditPreferences extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrEditPreferences extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -53,8 +47,10 @@
   @property({type: Object})
   editPrefs?: EditPreferencesInfo;
 
+  private readonly restApiService = appContext.restApiService;
+
   loadData() {
-    return this.$.restAPI.getEditPreferences().then(prefs => {
+    return this.restApiService.getEditPreferences().then(prefs => {
       this.editPrefs = prefs;
     });
   }
@@ -110,7 +106,7 @@
   save() {
     if (!this.editPrefs)
       return Promise.reject(new Error('Missing edit preferences'));
-    return this.$.restAPI.saveEditPreferences(this.editPrefs).then(() => {
+    return this.restApiService.saveEditPreferences(this.editPrefs).then(() => {
       this.hasUnsavedChanges = false;
     });
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
index 93a2d1c..a61d99b 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
@@ -25,7 +25,7 @@
   </style>
   <div id="editPreferences" class="gr-form-styles">
     <section>
-      <span class="title">Tab width</span>
+      <label for="editTabWidth" class="title">Tab width</label>
       <span class="value">
         <iron-input
           type="number"
@@ -37,6 +37,7 @@
         >
           <input
             is="iron-input"
+            id="editTabWidth"
             type="number"
             prevent-invalid-input=""
             allowed-pattern="[0-9]"
@@ -48,7 +49,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Columns</span>
+      <label for="editColumns" class="title">Columns</label>
       <span class="value">
         <iron-input
           type="number"
@@ -59,6 +60,7 @@
           on-change="_handleEditPrefsChanged"
         >
           <input
+            id="editColumns"
             is="iron-input"
             type="number"
             prevent-invalid-input=""
@@ -71,7 +73,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Indent unit</span>
+      <label for="indentUnit" class="title">Indent unit</label>
       <span class="value">
         <iron-input
           type="number"
@@ -83,6 +85,7 @@
         >
           <input
             is="iron-input"
+            id="indentUnit"
             type="number"
             prevent-invalid-input=""
             allowed-pattern="[0-9]"
@@ -94,7 +97,9 @@
       </span>
     </section>
     <section>
-      <span class="title">Syntax highlighting</span>
+      <label for="editSyntaxHighlighting" class="title"
+        >Syntax highlighting</label
+      >
       <span class="value">
         <input
           id="editSyntaxHighlighting"
@@ -105,7 +110,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Show tabs</span>
+      <label for="editShowTabs" class="title">Show tabs</label>
       <span class="value">
         <input
           id="editShowTabs"
@@ -116,7 +121,9 @@
       </span>
     </section>
     <section>
-      <span class="title">Show trailing whitespace</span>
+      <label for="showTrailingWhitespaceInput" class="title"
+        >Show trailing whitespace</label
+      >
       <span class="value">
         <input
           id="editShowTrailingWhitespaceInput"
@@ -127,7 +134,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Match brackets</span>
+      <label for="showMatchBrackets" class="title">Match brackets</label>
       <span class="value">
         <input
           id="showMatchBrackets"
@@ -138,7 +145,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Line wrapping</span>
+      <label for="editShowLineWrapping" class="title">Line wrapping</label>
       <span class="value">
         <input
           id="editShowLineWrapping"
@@ -149,7 +156,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Indent with tabs</span>
+      <label for="showIndentWithTabs" class="title">Indent with tabs</label>
       <span class="value">
         <input
           id="showIndentWithTabs"
@@ -160,7 +167,9 @@
       </span>
     </section>
     <section>
-      <span class="title">Auto close brackets</span>
+      <label for="showAutoCloseBrackets" class="title"
+        >Auto close brackets</label
+      >
       <span class="value">
         <input
           id="showAutoCloseBrackets"
@@ -171,5 +180,4 @@
       </span>
     </section>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.js b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.js
deleted file mode 100644
index b1b8b61..0000000
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.js
+++ /dev/null
@@ -1,110 +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-edit-preferences.js';
-
-const basicFixture = fixtureFromElement('gr-edit-preferences');
-
-suite('gr-edit-preferences tests', () => {
-  let element;
-
-  let editPreferences;
-
-  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(() => {
-    editPreferences = {
-      auto_close_brackets: false,
-      cursor_blink_rate: 0,
-      hide_line_numbers: false,
-      hide_top_menu: false,
-      indent_unit: 2,
-      indent_with_tabs: false,
-      key_map_type: 'DEFAULT',
-      line_length: 100,
-      line_wrapping: false,
-      match_brackets: true,
-      show_base: false,
-      show_tabs: true,
-      show_whitespace_errors: true,
-      syntax_highlighting: true,
-      tab_size: 8,
-      theme: 'DEFAULT',
-    };
-
-    stub('gr-rest-api-interface', {
-      getEditPreferences() {
-        return Promise.resolve(editPreferences);
-      },
-    });
-
-    element = basicFixture.instantiate();
-
-    return element.loadData();
-  });
-
-  test('renders', () => {
-    // Rendered with the expected preferences selected.
-    assert.equal(valueOf('Tab width', 'editPreferences')
-        .firstElementChild.bindValue, editPreferences.tab_size);
-    assert.equal(valueOf('Columns', 'editPreferences')
-        .firstElementChild.bindValue, editPreferences.line_length);
-    assert.equal(valueOf('Indent unit', 'editPreferences')
-        .firstElementChild.bindValue, editPreferences.indent_unit);
-    assert.equal(valueOf('Syntax highlighting', 'editPreferences')
-        .firstElementChild.checked, editPreferences.syntax_highlighting);
-    assert.equal(valueOf('Show tabs', 'editPreferences')
-        .firstElementChild.checked, editPreferences.show_tabs);
-    assert.equal(valueOf('Match brackets', 'editPreferences')
-        .firstElementChild.checked, editPreferences.match_brackets);
-    assert.equal(valueOf('Line wrapping', 'editPreferences')
-        .firstElementChild.checked, editPreferences.line_wrapping);
-    assert.equal(valueOf('Indent with tabs', 'editPreferences')
-        .firstElementChild.checked, editPreferences.indent_with_tabs);
-    assert.equal(valueOf('Auto close brackets', 'editPreferences')
-        .firstElementChild.checked, editPreferences.auto_close_brackets);
-
-    assert.isFalse(element.hasUnsavedChanges);
-  });
-
-  test('save changes', () => {
-    sinon.stub(element.$.restAPI, 'saveEditPreferences')
-        .returns(Promise.resolve());
-    const showTabsCheckbox = valueOf('Show tabs', 'editPreferences')
-        .firstElementChild;
-    showTabsCheckbox.checked = false;
-    element._handleEditShowTabsChanged();
-
-    assert.isTrue(element.hasUnsavedChanges);
-
-    // Save the change.
-    return element.save().then(() => {
-      assert.isFalse(element.hasUnsavedChanges);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.ts
new file mode 100644
index 0000000..bfdcaa0
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.ts
@@ -0,0 +1,125 @@
+/**
+ * @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-edit-preferences';
+import {stubRestApi} from '../../../test/test-utils';
+import {GrEditPreferences} from './gr-edit-preferences';
+import {EditPreferencesInfo} from '../../../types/common';
+import {IronInputElement} from '@polymer/iron-input';
+
+const basicFixture = fixtureFromElement('gr-edit-preferences');
+
+suite('gr-edit-preferences tests', () => {
+  let element: GrEditPreferences;
+
+  let editPreferences: EditPreferencesInfo;
+
+  function valueOf(title: string, id: string): Element {
+    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 () => {
+    editPreferences = {
+      auto_close_brackets: false,
+      cursor_blink_rate: 0,
+      hide_line_numbers: false,
+      hide_top_menu: false,
+      indent_unit: 2,
+      indent_with_tabs: false,
+      key_map_type: 'DEFAULT',
+      line_length: 100,
+      line_wrapping: false,
+      match_brackets: true,
+      show_base: false,
+      show_tabs: true,
+      show_whitespace_errors: true,
+      syntax_highlighting: true,
+      tab_size: 8,
+      theme: 'DEFAULT',
+    };
+
+    stubRestApi('getEditPreferences').returns(Promise.resolve(editPreferences));
+
+    element = basicFixture.instantiate();
+
+    await element.loadData();
+    await flush();
+  });
+
+  test('renders', () => {
+    // Rendered with the expected preferences selected.
+    const tabWidthInput = valueOf('Tab width', 'editPreferences')
+      .firstElementChild as IronInputElement;
+    assert.equal(tabWidthInput.bindValue, `${editPreferences.tab_size}`);
+
+    const columnsInput = valueOf('Columns', 'editPreferences')
+      .firstElementChild as IronInputElement;
+    assert.equal(columnsInput.bindValue, `${editPreferences.line_length}`);
+
+    const indentInput = valueOf('Indent unit', 'editPreferences')
+      .firstElementChild as IronInputElement;
+    assert.equal(indentInput.bindValue, `${editPreferences.indent_unit}`);
+
+    const syntaxInput = valueOf('Syntax highlighting', 'editPreferences')
+      .firstElementChild as HTMLInputElement;
+    assert.equal(syntaxInput.checked, editPreferences.syntax_highlighting);
+
+    const tabsInput = valueOf('Show tabs', 'editPreferences')
+      .firstElementChild as HTMLInputElement;
+    assert.equal(tabsInput.checked, editPreferences.show_tabs);
+
+    const bracketsInput = valueOf('Match brackets', 'editPreferences')
+      .firstElementChild as HTMLInputElement;
+    assert.equal(bracketsInput.checked, editPreferences.match_brackets);
+
+    const wrappingInput = valueOf('Line wrapping', 'editPreferences')
+      .firstElementChild as HTMLInputElement;
+    assert.equal(wrappingInput.checked, editPreferences.line_wrapping);
+
+    const indentTabsInput = valueOf('Indent with tabs', 'editPreferences')
+      .firstElementChild as HTMLInputElement;
+    assert.equal(indentTabsInput.checked, editPreferences.indent_with_tabs);
+
+    const autoCloseInput = valueOf('Auto close brackets', 'editPreferences')
+      .firstElementChild as HTMLInputElement;
+    assert.equal(autoCloseInput.checked, editPreferences.auto_close_brackets);
+
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('save changes', async () => {
+    const showTabsCheckbox = valueOf('Show tabs', 'editPreferences')
+      .firstElementChild as HTMLInputElement;
+    showTabsCheckbox.checked = false;
+    element._handleEditShowTabsChanged();
+
+    assert.isTrue(element.hasUnsavedChanges);
+
+    await element.save();
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+});
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
index fd10a16..74c6ac0 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
@@ -16,27 +16,16 @@
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-email-editor_html';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {EmailInfo} from '../../../types/common';
-
-export interface GrEmailEditor {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
 
 @customElement('gr-email-editor')
-export class GrEmailEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrEmailEditor extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -53,8 +42,10 @@
   @property({type: String})
   _newPreferred: string | null = null;
 
+  readonly restApiService = appContext.restApiService;
+
   loadData() {
-    return this.$.restAPI.getAccountEmails().then(emails => {
+    return this.restApiService.getAccountEmails().then(emails => {
       this._emails = emails ?? [];
     });
   }
@@ -63,12 +54,12 @@
     const promises: Promise<unknown>[] = [];
 
     for (const emailObj of this._emailsToRemove) {
-      promises.push(this.$.restAPI.deleteAccountEmail(emailObj.email));
+      promises.push(this.restApiService.deleteAccountEmail(emailObj.email));
     }
 
     if (this._newPreferred) {
       promises.push(
-        this.$.restAPI.setPreferredAccountEmail(this._newPreferred)
+        this.restApiService.setPreferredAccountEmail(this._newPreferred)
       );
     }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
index 525fca6..0591e4b 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
@@ -95,5 +95,4 @@
       </tbody>
     </table>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
index 18ff95c..e4c1584 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
@@ -15,9 +15,10 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-email-editor.js';
+import '../../../test/common-test-setup-karma';
+import './gr-email-editor';
 import {GrEmailEditor} from './gr-email-editor';
+import {spyRestApi, stubRestApi} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-email-editor');
 
@@ -31,11 +32,7 @@
       {email: 'email@three.com'},
     ];
 
-    stub('gr-rest-api-interface', {
-      getAccountEmails() {
-        return Promise.resolve(emails);
-      },
-    });
+    stubRestApi('getAccountEmails').returns(Promise.resolve(emails));
 
     element = basicFixture.instantiate();
 
@@ -113,12 +110,9 @@
     assert.equal(element._emailsToRemove[0].email, 'email@three.com');
   });
 
-  test('save changes', done => {
-    const deleteEmailStub = sinon.stub(element.$.restAPI, 'deleteAccountEmail');
-    const setPreferredStub = sinon.stub(
-      element.$.restAPI,
-      'setPreferredAccountEmail'
-    );
+  test('save changes', async () => {
+    const deleteEmailSpy = spyRestApi('deleteAccountEmail');
+    const setPreferredSpy = spyRestApi('setPreferredAccountEmail');
 
     const rows = element
       .shadowRoot!.querySelector('table')!
@@ -139,15 +133,10 @@
     assert.equal(element._emailsToRemove[0].email, 'email@one.com');
     assert.equal(element._emails.length, 2);
 
-    // Save the changes.
-    element.save().then(() => {
-      assert.equal(deleteEmailStub.callCount, 1);
-      assert.equal(deleteEmailStub.getCall(0).args[0], 'email@one.com');
-
-      assert.isTrue(setPreferredStub.called);
-      assert.equal(setPreferredStub.getCall(0).args[0], 'email@three.com');
-
-      done();
-    });
+    await element.save();
+    assert.equal(deleteEmailSpy.callCount, 1);
+    assert.equal(deleteEmailSpy.getCall(0).args[0], 'email@one.com');
+    assert.isTrue(setPreferredSpy.called);
+    assert.equal(setPreferredSpy.getCall(0).args[0], 'email@three.com');
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
index 21e414b..afbd67e 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
@@ -19,11 +19,8 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-gpg-editor_html';
 import {customElement, property} from '@polymer/decorators';
@@ -31,11 +28,10 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
 export interface GrGpgEditor {
   $: {
-    restAPI: RestApiService & Element;
     viewKeyOverlay: GrOverlay;
     addButton: GrButton;
     newKey: IronAutogrowTextareaElement;
@@ -48,9 +44,7 @@
   }
 }
 @customElement('gr-gpg-editor')
-export class GrGpgEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrGpgEditor extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -70,9 +64,11 @@
   @property({type: Array})
   _keysToRemove: GpgKeyInfo[] = [];
 
+  private readonly restApiService = appContext.restApiService;
+
   loadData() {
     this._keys = [];
-    return this.$.restAPI.getAccountGPGKeys().then(keys => {
+    return this.restApiService.getAccountGPGKeys().then(keys => {
       if (!keys) {
         return;
       }
@@ -86,7 +82,7 @@
 
   save() {
     const promises = this._keysToRemove.map(key =>
-      this.$.restAPI.deleteAccountGPGKey(key.id!)
+      this.restApiService.deleteAccountGPGKey(key.id!)
     );
 
     return Promise.all(promises).then(() => {
@@ -117,7 +113,7 @@
   _handleAddKey() {
     this.$.addButton.disabled = true;
     this.$.newKey.disabled = true;
-    return this.$.restAPI
+    return this.restApiService
       .addAccountGPGKey({add: [this._newKey.trim()]})
       .then(() => {
         this.$.newKey.disabled = false;
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
index 432bc4f..69ac702 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
@@ -133,5 +133,4 @@
       >
     </fieldset>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js
index 2792176..8820748 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-gpg-editor.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-gpg-editor');
 
@@ -50,9 +51,7 @@
       },
     };
 
-    stub('gr-rest-api-interface', {
-      getAccountGPGKeys() { return Promise.resolve(keys); },
-    });
+    stubRestApi('getAccountGPGKeys').returns(Promise.resolve(keys));
 
     element = basicFixture.instantiate();
 
@@ -74,7 +73,7 @@
   test('remove key', done => {
     const lastKey = keys[Object.keys(keys)[1]];
 
-    const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountGPGKey')
+    const saveStub = stubRestApi('deleteAccountGPGKey')
         .callsFake(() => Promise.resolve());
 
     assert.equal(element._keysToRemove.length, 0);
@@ -130,7 +129,8 @@
       },
     };
 
-    const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey').callsFake(
+    const addStub = stubRestApi(
+        'addAccountGPGKey').callsFake(
         () => Promise.resolve(newKeyObject));
 
     element._newKey = newKeyString;
@@ -155,7 +155,8 @@
   test('add invalid key', done => {
     const newKeyString = 'not even close to valid';
 
-    const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey').callsFake(
+    const addStub = stubRestApi(
+        'addAccountGPGKey').callsFake(
         () => Promise.reject(new Error('error')));
 
     element._newKey = newKeyString;
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
index d631c53..a367969 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
@@ -16,21 +16,12 @@
  */
 import '../../../styles/shared-styles';
 import '../../../styles/gr-form-styles';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-group-list_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {customElement, property} from '@polymer/decorators';
 import {GroupInfo, GroupId} from '../../../types/common';
-
-export interface GrGroupList {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -38,9 +29,7 @@
   }
 }
 @customElement('gr-group-list')
-export class GrGroupList extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrGroupList extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -48,8 +37,10 @@
   @property({type: Array})
   _groups: GroupInfo[] = [];
 
+  private readonly restApiService = appContext.restApiService;
+
   loadData() {
-    return this.$.restAPI.getAccountGroups().then(groups => {
+    return this.restApiService.getAccountGroups().then(groups => {
       if (!groups) return;
       this._groups = groups.sort((a, b) =>
         (a.name || '').localeCompare(b.name || '')
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.ts b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.ts
index e52583d..5f98a82 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.ts
@@ -46,9 +46,7 @@
         <template is="dom-repeat" items="[[_groups]]">
           <tr>
             <td class="nameColumn">
-              <a href$="[[_computeGroupPath(item)]]">
-                [[item.name]]
-              </a>
+              <a href$="[[_computeGroupPath(item)]]"> [[item.name]] </a>
             </td>
             <td>[[item.description]]</td>
             <td class="visibleCell">[[_computeVisibleToAll(item)]]</td>
@@ -57,5 +55,4 @@
       </tbody>
     </table>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js
index e19345a..e048103 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-group-list.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-group-list');
 
@@ -45,9 +46,7 @@
       name: 'Group 3',
     }];
 
-    stub('gr-rest-api-interface', {
-      getAccountGroups() { return Promise.resolve(groups); },
-    });
+    stubRestApi('getAccountGroups').returns(Promise.resolve(groups));
 
     element = basicFixture.instantiate();
 
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
index 02683e3..610ea30 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
@@ -18,15 +18,12 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 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 {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -36,15 +33,12 @@
 
 export interface GrHttpPassword {
   $: {
-    restAPI: RestApiService & Element;
     generatedPasswordOverlay: GrOverlay;
   };
 }
 
 @customElement('gr-http-password')
-export class GrHttpPassword extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrHttpPassword extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -58,9 +52,11 @@
   @property({type: String})
   _passwordUrl: string | null = null;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.loadData();
   }
 
@@ -68,7 +64,7 @@
     const promises = [];
 
     promises.push(
-      this.$.restAPI.getAccount().then(account => {
+      this.restApiService.getAccount().then(account => {
         if (account) {
           this._username = account.username;
         }
@@ -76,7 +72,7 @@
     );
 
     promises.push(
-      this.$.restAPI.getConfig().then(info => {
+      this.restApiService.getConfig().then(info => {
         if (info) {
           this._passwordUrl = info.auth.http_password_url || null;
         } else {
@@ -91,7 +87,7 @@
   _handleGenerateTap() {
     this._generatedPassword = 'Generating...';
     this.$.generatedPasswordOverlay.open();
-    this.$.restAPI.generateAccountHttpPassword().then(newPassword => {
+    this.restApiService.generateAccountHttpPassword().then(newPassword => {
       this._generatedPassword = newPassword;
     });
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
index 41084b5..549fc93 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
@@ -94,5 +94,4 @@
       >
     </div>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.js
index 920ad48..e403b4f 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-http-password.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-http-password');
 
@@ -29,10 +30,8 @@
     account = {username: 'user name'};
     config = {auth: {}};
 
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve(account); },
-      getConfig() { return Promise.resolve(config); },
-    });
+    stubRestApi('getAccount').returns(Promise.resolve(account));
+    stubRestApi('getConfig').returns(Promise.resolve(config));
 
     element = basicFixture.instantiate();
     element.loadData().then(() => { flush(done); });
@@ -42,7 +41,7 @@
     const button = element.$.generateButton;
     const nextPassword = 'the new password';
     let generateResolve;
-    const generateStub = sinon.stub(element.$.restAPI,
+    const generateStub = stubRestApi(
         'generateAccountHttpPassword')
         .callsFake(() => new Promise(resolve => {
           generateResolve = resolve;
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
index 5f2b6a5..d65304c 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
@@ -19,31 +19,25 @@
 import '../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-identities_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {AccountExternalIdInfo, ServerInfo} from '../../../types/common';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {PolymerDomRepeatEvent} from '../../../types/types';
+import {appContext} from '../../../services/app-context';
 
 const AUTH = ['OPENID', 'OAUTH'];
 
 export interface GrIdentities {
   $: {
-    restAPI: RestApiService & Element;
     overlay: GrOverlay;
   };
 }
 
 @customElement('gr-identities')
-export class GrIdentities extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrIdentities extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -63,8 +57,10 @@
   })
   _showLinkAnotherIdentity?: boolean;
 
+  private readonly restApiService = appContext.restApiService;
+
   loadData() {
-    return this.$.restAPI.getExternalIds().then(id => {
+    return this.restApiService.getExternalIds().then(id => {
       this._identities = id ?? [];
     });
   }
@@ -79,9 +75,11 @@
 
   _handleDeleteItemConfirm() {
     this.$.overlay.close();
-    return this.$.restAPI.deleteAccountIdentity([this._idName!]).then(() => {
-      this.loadData();
-    });
+    return this.restApiService
+      .deleteAccountIdentity([this._idName!])
+      .then(() => {
+        this.loadData();
+      });
   }
 
   _handleConfirmDialogCancel() {
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts
index 1472103..ef5d72f 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
@@ -64,9 +64,7 @@
             filter="filterIdentities"
           >
             <tr>
-              <td class="statusColumn">
-                [[_computeIsTrusted(item.trusted)]]
-              </td>
+              <td class="statusColumn">[[_computeIsTrusted(item.trusted)]]</td>
               <td class="emailAddressColumn">[[item.email_address]]</td>
               <td class="identityColumn">
                 [[_computeIdentity(item.identity)]]
@@ -103,5 +101,4 @@
       item-type="id"
     ></gr-confirm-delete-item-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
index 8af5bd0..867473f 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-identities.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-identities');
 
@@ -39,14 +40,12 @@
     },
   ];
 
-  setup(done => {
-    stub('gr-rest-api-interface', {
-      getExternalIds() { return Promise.resolve(ids); },
-    });
+  setup(async () => {
+    stubRestApi('getExternalIds').returns(Promise.resolve(ids));
 
     element = basicFixture.instantiate();
-
-    element.loadData().then(() => { flush(done); });
+    await element.loadData();
+    await flush();
   });
 
   test('renders', () => {
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
index 0498b35..0ff5e98 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
@@ -17,21 +17,16 @@
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import '../../../styles/gr-form-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-menu-editor_html';
 import {customElement, property} from '@polymer/decorators';
 import {TopMenuItemInfo} from '../../../types/common';
 
 @customElement('gr-menu-editor')
-export class GrMenuEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrMenuEditor extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
index 0e73062..3763fbf 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
@@ -17,23 +17,19 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-registration-dialog_html';
 import {customElement, property, observe} from '@polymer/decorators';
 import {ServerInfo, AccountDetailInfo} from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {EditableAccountField} from '../../../constants/constants';
+import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
 
 export interface GrRegistrationDialog {
   $: {
-    restAPI: RestApiService & Element;
     name: HTMLInputElement;
     username: HTMLInputElement;
-    email: HTMLSelectElement;
   };
 }
 
@@ -44,9 +40,7 @@
 }
 
 @customElement('gr-registration-dialog')
-export class GrRegistrationDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRegistrationDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -78,40 +72,39 @@
   _serverConfig?: ServerInfo;
 
   @property({
-    computed: '_computeUsernameMutable(_serverConfig,_account.username)',
+    computed: '_computeUsernameMutable(_account.username)',
     type: Boolean,
   })
   _usernameMutable = false;
 
+  @property({type: Boolean})
+  _hasUsernameChange?: boolean;
+
+  @property({type: String, observer: '_usernameChanged'})
+  _username?: string;
+
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   ready() {
     super.ready();
     this._ensureAttribute('role', 'dialog');
   }
 
-  _computeUsernameMutable(config?: ServerInfo, username?: string) {
-    // Polymer 2: check for undefined
-    // username is not being checked for undefined as we want to avoid
-    // setting it null explicitly to trigger the computation
-    if (config === undefined) {
-      return false;
-    }
-
-    return (
-      config.auth.editable_account_fields.includes(
-        EditableAccountField.USER_NAME
-      ) && !username
-    );
-  }
-
   loadData() {
     this._loading = true;
 
-    const loadAccount = this.$.restAPI.getAccount().then(account => {
-      this._account = {...this._account, ...account};
+    const loadAccount = this.restApiService.getAccount().then(account => {
+      if (!account) return;
+      this._hasUsernameChange = false;
+      // Provide predefined value for username to trigger computation of
+      // username mutability.
+      account.username = account.username || '';
+      this._account = account;
+      this._username = account.username;
     });
 
-    const loadConfig = this.$.restAPI.getConfig().then(config => {
+    const loadConfig = this.restApiService.getConfig().then(config => {
       this._serverConfig = config;
     });
 
@@ -120,25 +113,39 @@
     });
   }
 
+  _usernameChanged() {
+    if (this._loading || !this._account) {
+      return;
+    }
+    this._hasUsernameChange =
+      (this._account.username || '') !== (this._username || '');
+  }
+
+  _computeUsernameMutable(username?: string) {
+    // Username may not be changed once it is set.
+    return !username;
+  }
+
+  _computeUsernameEditable(config?: ServerInfo) {
+    return !!config?.auth.editable_account_fields.includes(
+      EditableAccountField.USER_NAME
+    );
+  }
+
   _save() {
     this._saving = true;
-    const promises = [
-      this.$.restAPI.setAccountName(this.$.name.value),
-      this.$.restAPI.setPreferredAccountEmail(this.$.email.value || ''),
-    ];
 
-    if (this._usernameMutable) {
-      promises.push(this.$.restAPI.setAccountUsername(this.$.username.value));
+    const promises = [this.restApiService.setAccountName(this.$.name.value)];
+
+    // Note that we are intentionally not acting on this._username being the
+    // empty string (which is falsy).
+    if (this._hasUsernameChange && this._usernameMutable && this._username) {
+      promises.push(this.restApiService.setAccountUsername(this._username));
     }
 
     return Promise.all(promises).then(() => {
       this._saving = false;
-      this.dispatchEvent(
-        new CustomEvent('account-detail-update', {
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireEvent(this, 'account-detail-update');
     });
   }
 
@@ -154,20 +161,11 @@
 
   close() {
     this._saving = true; // disable buttons indefinitely
-    this.dispatchEvent(
-      new CustomEvent('close', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'close');
   }
 
-  _computeSaveDisabled(name?: string, email?: string, saving?: boolean) {
-    return !name || !email || saving;
-  }
-
-  _computeUsernameClass(usernameMutable: boolean) {
-    return usernameMutable ? '' : 'hide';
+  _computeSaveDisabled(name?: string, username?: string, saving?: boolean) {
+    return saving || (!name && !username);
   }
 
   @observe('_loading')
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts
index 11fbbc9..4b86709 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
@@ -59,9 +59,6 @@
     input {
       width: 20em;
     }
-    section.hide {
-      display: none;
-    }
   </style>
   <div class="container gr-form-styles">
     <header>Please confirm your contact information</header>
@@ -75,36 +72,36 @@
       </p>
       <hr />
       <section>
-        <div class="title">Full Name</div>
-        <iron-input bind-value="{{_account.name}}">
-          <input
-            is="iron-input"
-            id="name"
-            bind-value="{{_account.name}}"
-            disabled="[[_saving]]"
-          />
-        </iron-input>
+        <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]]"
+            />
+          </iron-input>
+        </span>
       </section>
-      <section class$="[[_computeUsernameClass(_usernameMutable)]]">
-        <div class="title">Username</div>
-        <iron-input bind-value="{{_account.username}}">
-          <input
-            is="iron-input"
-            id="username"
-            bind-value="{{_account.username}}"
-            disabled="[[_saving]]"
-          />
-        </iron-input>
-      </section>
-      <section>
-        <div class="title">Preferred Email</div>
-        <select id="email" disabled="[[_saving]]">
-          <option value="[[_account.email]]">[[_account.email]]</option>
-          <template is="dom-repeat" items="[[_account.secondary_emails]]">
-            <option value="[[item]]">[[item]]</option>
-          </template>
-        </select>
-      </section>
+      <template is="dom-if" if="[[_computeUsernameEditable(_serverConfig)]]">
+        <section>
+          <span class="title">Username</span>
+          <span hidden$="[[_usernameMutable]]" class="value"
+            >[[_username]]</span
+          >
+          <span hidden$="[[!_usernameMutable]]" class="value">
+            <iron-input bind-value="{{_username}}">
+              <input
+                is="iron-input"
+                id="username"
+                bind-value="{{_username}}"
+                disabled="[[_saving]]"
+              />
+            </iron-input>
+          </span>
+        </section>
+      </template>
       <hr />
       <p>
         More configuration options for Gerrit may be found in the
@@ -123,11 +120,10 @@
         id="saveButton"
         primary=""
         link=""
-        disabled="[[_computeSaveDisabled(_account.name, _account.email, _saving)]]"
+        disabled="[[_computeSaveDisabled(_account.name, _username, _saving)]]"
         on-click="_handleSave"
         >Save</gr-button
       >
     </footer>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js
deleted file mode 100644
index 468ef57..0000000
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js
+++ /dev/null
@@ -1,163 +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-registration-dialog.js';
-
-const basicFixture = fixtureFromElement('gr-registration-dialog');
-
-suite('gr-registration-dialog tests', () => {
-  let element;
-  let account;
-
-  let _listeners;
-
-  setup(() => {
-    _listeners = {};
-
-    account = {
-      name: 'name',
-      username: null,
-      email: 'email',
-      secondary_emails: [
-        'email2',
-        'email3',
-      ],
-    };
-
-    stub('gr-rest-api-interface', {
-      getAccount() {
-        return Promise.resolve(account);
-      },
-      setAccountName(name) {
-        account.name = name;
-        return Promise.resolve();
-      },
-      setAccountUsername(username) {
-        account.username = username;
-        return Promise.resolve();
-      },
-      setPreferredAccountEmail(email) {
-        account.email = email;
-        return Promise.resolve();
-      },
-      getConfig() {
-        return Promise.resolve(
-            {auth: {editable_account_fields: ['USER_NAME']}});
-      },
-    });
-
-    element = basicFixture.instantiate();
-
-    return element.loadData();
-  });
-
-  teardown(() => {
-    for (const eventType in _listeners) {
-      if (_listeners.hasOwnProperty(eventType)) {
-        element.removeEventListener(eventType, _listeners[eventType]);
-      }
-    }
-  });
-
-  function listen(eventType) {
-    return new Promise(resolve => {
-      _listeners[eventType] = function() { resolve(); };
-      element.addEventListener(eventType, _listeners[eventType]);
-    });
-  }
-
-  function save(opt_action) {
-    const promise = listen('account-detail-update');
-    if (opt_action) {
-      opt_action();
-    } else {
-      MockInteractions.tap(element.$.saveButton);
-    }
-    return promise;
-  }
-
-  function close(opt_action) {
-    const promise = listen('close');
-    if (opt_action) {
-      opt_action();
-    } else {
-      MockInteractions.tap(element.$.closeButton);
-    }
-    return promise;
-  }
-
-  test('fires the close event on close', done => {
-    close().then(done);
-  });
-
-  test('fires the close event on save', done => {
-    close(() => {
-      MockInteractions.tap(element.$.saveButton);
-    }).then(done);
-  });
-
-  test('saves account details', done => {
-    flush(() => {
-      element.$.name.value = 'new name';
-      element.$.username.value = 'new username';
-      element.$.email.value = 'email3';
-
-      // Nothing should be committed yet.
-      assert.equal(account.name, 'name');
-      assert.isNotOk(account.username);
-      assert.equal(account.email, 'email');
-
-      // Save and verify new values are committed.
-      save()
-          .then(() => {
-            assert.equal(account.name, 'new name');
-            assert.equal(account.username, 'new username');
-            assert.equal(account.email, 'email3');
-          })
-          .then(done);
-    });
-  });
-
-  test('email select properly populated', done => {
-    element._account = {email: 'foo', secondary_emails: ['bar', 'baz']};
-    flush(() => {
-      assert.equal(element.$.email.value, 'foo');
-      done();
-    });
-  });
-
-  test('save btn disabled', () => {
-    const compute = element._computeSaveDisabled;
-    assert.isTrue(compute('', '', false));
-    assert.isTrue(compute('', 'test', false));
-    assert.isTrue(compute('test', '', false));
-    assert.isTrue(compute('test', 'test', true));
-    assert.isFalse(compute('test', 'test', false));
-  });
-
-  test('_computeUsernameMutable', () => {
-    assert.isTrue(element._computeUsernameMutable(
-        {auth: {editable_account_fields: ['USER_NAME']}}, null));
-    assert.isFalse(element._computeUsernameMutable(
-        {auth: {editable_account_fields: ['USER_NAME']}}, 'abc'));
-    assert.isFalse(element._computeUsernameMutable(
-        {auth: {editable_account_fields: []}}, null));
-    assert.isFalse(element._computeUsernameMutable(
-        {auth: {editable_account_fields: []}}, 'abc'));
-  });
-});
-
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
new file mode 100644
index 0000000..6c21e58
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts
@@ -0,0 +1,164 @@
+/**
+ * @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 {GrRegistrationDialog} from './gr-registration-dialog';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {AccountDetailInfo, Timestamp} from '../../../types/common';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {AuthType, EditableAccountField} from '../../../constants/constants';
+import {createServerInfo} from '../../../test/test-data-generators';
+
+const basicFixture = fixtureFromElement('gr-registration-dialog');
+
+suite('gr-registration-dialog tests', () => {
+  let element: GrRegistrationDialog;
+  let account: AccountDetailInfo;
+
+  let _listeners: {[key: string]: EventListenerOrEventListenerObject};
+
+  setup(() => {
+    _listeners = {};
+
+    account = {
+      name: 'name',
+      registered_on: '2018-02-08 18:49:18.000000000' as Timestamp,
+    };
+
+    stubRestApi('getAccount').returns(Promise.resolve(account));
+    stubRestApi('setAccountName').callsFake(name => {
+      account.name = name;
+      return Promise.resolve();
+    });
+    stubRestApi('setAccountUsername').callsFake(username => {
+      account.username = username;
+      return Promise.resolve();
+    });
+    stubRestApi('getConfig').returns(
+      Promise.resolve({
+        ...createServerInfo(),
+        auth: {
+          auth_type: AuthType.HTTP,
+          editable_account_fields: [EditableAccountField.USER_NAME],
+        },
+      })
+    );
+
+    element = basicFixture.instantiate();
+
+    return element.loadData();
+  });
+
+  teardown(() => {
+    for (const [eventType, listeners] of Object.entries(_listeners)) {
+      element.removeEventListener(eventType, listeners);
+    }
+  });
+
+  function listen(eventType: string): Promise<void> {
+    return new Promise(resolve => {
+      _listeners[eventType] = function () {
+        resolve();
+      };
+      element.addEventListener(eventType, _listeners[eventType]);
+    });
+  }
+
+  function save() {
+    const promise = listen('account-detail-update');
+    MockInteractions.tap(queryAndAssert(element, '#saveButton'));
+    return promise;
+  }
+
+  function close(opt_action?: Function) {
+    const promise = listen('close');
+    if (opt_action) {
+      opt_action();
+    } else {
+      MockInteractions.tap(queryAndAssert(element, '#closeButton'));
+    }
+    return promise;
+  }
+
+  test('fires the close event on close', done => {
+    close().then(done);
+  });
+
+  test('fires the close event on save', done => {
+    close(() =>
+      MockInteractions.tap(queryAndAssert(element, '#saveButton'))
+    ).then(done);
+  });
+
+  test('saves account details', done => {
+    flush(() => {
+      element.$.name.value = 'new name';
+
+      element.set('_account.username', '');
+      element._hasUsernameChange = false;
+      assert.isTrue(element._usernameMutable);
+
+      element.set('_username', 'new 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);
+    });
+  });
+
+  test('save btn disabled', () => {
+    const compute = element._computeSaveDisabled;
+    assert.isTrue(compute('', '', false));
+    assert.isFalse(compute('', 'test', false));
+    assert.isFalse(compute('test', '', false));
+    assert.isTrue(compute('test', 'test', true));
+    assert.isFalse(compute('test', 'test', false));
+  });
+
+  test('_computeUsernameMutable', () => {
+    assert.isTrue(element._computeUsernameMutable(undefined));
+    assert.isFalse(element._computeUsernameMutable('abc'));
+  });
+
+  test('_computeUsernameEditable', () => {
+    assert.isTrue(
+      element._computeUsernameEditable({
+        ...createServerInfo(),
+        auth: {
+          auth_type: AuthType.HTTP,
+          editable_account_fields: [EditableAccountField.USER_NAME],
+        },
+      })
+    );
+    assert.isFalse(
+      element._computeUsernameEditable({
+        ...createServerInfo(),
+        auth: {
+          auth_type: AuthType.HTTP,
+          editable_account_fields: [],
+        },
+      })
+    );
+  });
+});
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 5d80a84d..7540960 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,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-settings-item_html';
 import {property, customElement} from '@polymer/decorators';
@@ -27,9 +25,7 @@
 }
 
 @customElement('gr-settings-item')
-class GrSettingsItem extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrSettingsItem extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
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 e288d20..8a6c084 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
@@ -15,8 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/gr-page-nav-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-settings-menu-item_html';
 import {property, customElement} from '@polymer/decorators';
@@ -28,9 +26,7 @@
 }
 
 @customElement('gr-settings-menu-item')
-class GrSettingsMenuItem extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrSettingsMenuItem extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index 9f9840b..eee850e 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -30,7 +30,6 @@
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-diff-preferences/gr-diff-preferences';
 import '../../shared/gr-page-nav/gr-page-nav';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
 import '../gr-account-info/gr-account-info';
 import '../gr-agreements-list/gr-agreements-list';
@@ -43,8 +42,6 @@
 import '../gr-menu-editor/gr-menu-editor';
 import '../gr-ssh-editor/gr-ssh-editor';
 import '../gr-watched-projects-editor/gr-watched-projects-editor';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-settings-view_html';
 import {getDocsBaseUrl} from '../../../utils/url-util';
@@ -57,7 +54,6 @@
 import {GrIdentities} from '../gr-identities/gr-identities';
 import {GrEditPreferences} from '../gr-edit-preferences/gr-edit-preferences';
 import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   PreferencesInput,
   ServerInfo,
@@ -65,9 +61,11 @@
 } from '../../../types/common';
 import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
 import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
-import {GerritView} from '../../core/gr-navigation/gr-navigation';
 import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
 import {CustomKeyboardEvent} from '../../../types/events';
+import {fireAlert, fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {GerritView} from '../../../services/router/router-model';
 
 const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
   'changes_per_page',
@@ -101,7 +99,6 @@
 
 export interface GrSettingsView {
   $: {
-    restAPI: RestApiService & Element;
     accountInfo: GrAccountInfo;
     watchedProjectsEditor: GrWatchedProjectsEditor;
     groupList: GrGroupList;
@@ -120,9 +117,7 @@
 }
 
 @customElement('gr-settings-view')
-export class GrSettingsView extends ChangeTableMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrSettingsView extends ChangeTableMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -148,9 +143,6 @@
   @property({type: Boolean})
   _accountInfoChanged?: boolean;
 
-  @property({type: Array})
-  _changeTableColumnsNotDisplayed?: string[];
-
   @property({type: Object})
   _localPrefs: PreferencesInput = {};
 
@@ -213,19 +205,15 @@
 
   public _testOnly_loadingPromise?: Promise<void>;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     // Polymer 2: anchor tag won't work on shadow DOM
     // we need to manually calling scrollIntoView when hash changed
-    this.listen(window, 'location-change', '_handleLocationChange');
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Settings'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    window.addEventListener('location-change', this.handleLocationChange);
+    fireTitleChange(this, 'Settings');
 
     this._isDark = !!window.localStorage.getItem('dark-theme');
 
@@ -239,20 +227,23 @@
     ];
 
     promises.push(
-      this.$.restAPI.getPreferences().then(prefs => {
+      this.restApiService.getPreferences().then(prefs => {
         if (!prefs) {
           throw new Error('getPreferences returned undefined');
         }
         this.prefs = prefs;
         this._showNumber = !!prefs.legacycid_in_change_table;
         this._copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
-        this._cloneMenu(prefs.my);
-        this._cloneChangeTableColumns(prefs.change_table);
+        this._localMenu = this._cloneMenu(prefs.my);
+        this._localChangeTableColumns =
+          prefs.change_table.length === 0
+            ? this.columnNames
+            : this.renameProjectToRepoColumn(prefs.change_table);
       })
     );
 
     promises.push(
-      this.$.restAPI.getConfig().then(config => {
+      this.restApiService.getConfig().then(config => {
         this._serverConfig = config;
         const configPromises: Array<Promise<void>> = [];
 
@@ -269,7 +260,7 @@
         }
 
         configPromises.push(
-          getDocsBaseUrl(config, this.$.restAPI).then(baseUrl => {
+          getDocsBaseUrl(config, this.restApiService).then(baseUrl => {
             this._docsBaseUrl = baseUrl;
           })
         );
@@ -284,18 +275,14 @@
       this.params.emailToken
     ) {
       promises.push(
-        this.$.restAPI.confirmEmail(this.params.emailToken).then(message => {
-          if (message) {
-            this.dispatchEvent(
-              new CustomEvent('show-alert', {
-                detail: {message},
-                composed: true,
-                bubbles: true,
-              })
-            );
-          }
-          this.$.emailEditor.loadData();
-        })
+        this.restApiService
+          .confirmEmail(this.params.emailToken)
+          .then(message => {
+            if (message) {
+              fireAlert(this, message);
+            }
+            this.$.emailEditor.loadData();
+          })
       );
     } else {
       promises.push(this.$.emailEditor.loadData());
@@ -305,16 +292,16 @@
       this._loading = false;
 
       // Handle anchor tag for initial load
-      this._handleLocationChange();
+      this.handleLocationChange();
     });
   }
 
-  detached() {
-    super.detached();
-    this.unlisten(window, 'location-change', '_handleLocationChange');
+  disconnectedCallback() {
+    window.removeEventListener('location-change', this.handleLocationChange);
+    super.disconnectedCallback();
   }
 
-  _handleLocationChange() {
+  private readonly handleLocationChange = () => {
     // Handle anchor tag after dom attached
     const urlHash = window.location.hash;
     if (urlHash) {
@@ -324,7 +311,7 @@
         elem.scrollIntoView();
       }
     }
-  }
+  };
 
   reloadAccountDetail() {
     Promise.all([this.$.accountInfo.loadData(), this.$.emailEditor.loadData()]);
@@ -350,29 +337,8 @@
   }
 
   _cloneMenu(prefs: TopMenuItemInfo[]) {
-    const menu = [];
-    for (const item of prefs) {
-      menu.push({
-        name: item.name,
-        url: item.url,
-        target: item.target,
-      });
-    }
-    this._localMenu = menu;
-  }
-
-  _cloneChangeTableColumns(changeTable: string[]) {
-    let columns = this.getVisibleColumns(changeTable);
-
-    if (columns.length === 0) {
-      columns = this.columnNames;
-      this._changeTableColumnsNotDisplayed = [];
-    } else {
-      this._changeTableColumnsNotDisplayed = this.getComplementColumns(
-        changeTable
-      );
-    }
-    this._localChangeTableColumns = columns;
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    return prefs.map(({id, ...item}) => item);
   }
 
   @observe('_localChangeTableColumns', '_showNumber')
@@ -438,7 +404,7 @@
   _handleSavePreferences() {
     this._copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
 
-    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+    return this.restApiService.savePreferences(this.prefs).then(() => {
       this._prefsChanged = false;
     });
   }
@@ -446,8 +412,7 @@
   _handleSaveChangeTable() {
     this.set('prefs.change_table', this._localChangeTableColumns);
     this.set('prefs.legacycid_in_change_table', this._showNumber);
-    this._cloneChangeTableColumns(this._localChangeTableColumns);
-    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+    return this.restApiService.savePreferences(this.prefs).then(() => {
       this._changeTableChanged = false;
     });
   }
@@ -462,16 +427,15 @@
 
   _handleSaveMenu() {
     this.set('prefs.my', this._localMenu);
-    this._cloneMenu(this._localMenu);
-    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+    return this.restApiService.savePreferences(this.prefs).then(() => {
       this._menuChanged = false;
     });
   }
 
   _handleResetMenuButton() {
-    return this.$.restAPI.getDefaultPreferences().then(data => {
+    return this.restApiService.getDefaultPreferences().then(data => {
       if (data?.my) {
-        this._cloneMenu(data.my);
+        this._localMenu = this._cloneMenu(data.my);
       }
     });
   }
@@ -508,7 +472,7 @@
     if (!this._isNewEmailValid(this._newEmail)) return;
 
     this._addingEmail = true;
-    this.$.restAPI.addAccountEmail(this._newEmail).then(response => {
+    this.restApiService.addAccountEmail(this._newEmail).then(response => {
       this._addingEmail = false;
 
       // If it was unsuccessful.
@@ -542,15 +506,7 @@
       applyDarkTheme();
     }
     this._isDark = !!window.localStorage.getItem('dark-theme');
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {
-          message: `Theme changed to ${this._isDark ? 'dark' : 'light'}.`,
-        },
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireAlert(this, `Theme changed to ${this._isDark ? 'dark' : 'light'}.`);
   }
 
   _showHttpAuth(config?: ServerInfo) {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
index 78f84b1..6b45e1c 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
@@ -33,7 +33,7 @@
     #email {
       margin-bottom: var(--spacing-l);
     }
-    main section.darkToggle {
+    .main section.darkToggle {
       display: block;
     }
     .filters p,
@@ -75,14 +75,10 @@
           <li><a href="#HTTPCredentials">HTTP Credentials</a></li>
         </template>
         <li hidden$="[[!_serverConfig.sshd]]">
-          <a href="#SSHKeys">
-            SSH Keys
-          </a>
+          <a href="#SSHKeys"> SSH Keys </a>
         </li>
         <li hidden$="[[!_serverConfig.receive.enable_signed_push]]">
-          <a href="#GPGKeys">
-            GPG Keys
-          </a>
+          <a href="#GPGKeys"> GPG Keys </a>
         </li>
         <li><a href="#Groups">Groups</a></li>
         <li><a href="#Identities">Identities</a></li>
@@ -99,7 +95,7 @@
         </gr-endpoint-decorator>
       </ul>
     </gr-page-nav>
-    <main class="gr-form-styles">
+    <div class="main gr-form-styles">
       <h1 class="heading-1">User Settings</h1>
       <section class="darkToggle">
         <div class="toggle">
@@ -107,7 +103,7 @@
             aria-labelledby="darkThemeToggleLabel"
             checked="[[_isDark]]"
             on-change="_handleToggleDark"
-            on-tap="_onTapDarkToggle"
+            on-click="_onTapDarkToggle"
           ></paper-toggle-button>
           <div id="darkThemeToggleLabel">Dark theme (alpha)</div>
         </div>
@@ -136,10 +132,12 @@
       </h2>
       <fieldset id="preferences">
         <section>
-          <span class="title">Changes per page</span>
+          <label class="title" for="changesPerPageSelect"
+            >Changes per page</label
+          >
           <span class="value">
             <gr-select bind-value="{{_localPrefs.changes_per_page}}">
-              <select>
+              <select id="changesPerPageSelect">
                 <option value="10">10 rows per page</option>
                 <option value="25">25 rows per page</option>
                 <option value="50">50 rows per page</option>
@@ -149,10 +147,12 @@
           </span>
         </section>
         <section>
-          <span class="title">Date/time format</span>
+          <label class="title" for="dateTimeFormatSelect"
+            >Date/time format</label
+          >
           <span class="value">
             <gr-select bind-value="{{_localPrefs.date_format}}">
-              <select>
+              <select id="dateTimeFormatSelect">
                 <option value="STD">Jun 3 ; Jun 3, 2016</option>
                 <option value="US">06/03 ; 06/03/16</option>
                 <option value="ISO">06-03 ; 2016-06-03</option>
@@ -160,7 +160,10 @@
                 <option value="UK">03/06 ; 03/06/2016</option>
               </select>
             </gr-select>
-            <gr-select bind-value="{{_localPrefs.time_format}}">
+            <gr-select
+              bind-value="{{_localPrefs.time_format}}"
+              aria-label="Time Format"
+            >
               <select>
                 <option value="HHMM_12">4:10 PM</option>
                 <option value="HHMM_24">16:10</option>
@@ -169,25 +172,27 @@
           </span>
         </section>
         <section>
-          <span class="title">Email notifications</span>
+          <label class="title" for="emailNotificationsSelect"
+            >Email notifications</label
+          >
           <span class="value">
             <gr-select bind-value="{{_localPrefs.email_strategy}}">
-              <select>
+              <select id="emailNotificationsSelect">
                 <option value="CC_ON_OWN_COMMENTS">Every comment</option>
                 <option value="ENABLED">Only comments left by others</option>
-                <option value="ATTENTION_SET_ONLY"
-                  >Only when I am in the attention set</option
-                >
+                <option value="ATTENTION_SET_ONLY">
+                  Only when I am in the attention set
+                </option>
                 <option value="DISABLED">None</option>
               </select>
             </gr-select>
           </span>
         </section>
         <section hidden$="[[!_localPrefs.email_format]]">
-          <span class="title">Email format</span>
+          <label class="title" for="emailFormatSelect">Email format</label>
           <span class="value">
             <gr-select bind-value="{{_localPrefs.email_format}}">
-              <select>
+              <select id="emailFormatSelect">
                 <option value="HTML_PLAINTEXT">HTML and plaintext</option>
                 <option value="PLAINTEXT">Plaintext only</option>
               </select>
@@ -206,7 +211,9 @@
           </span>
         </section>
         <section>
-          <span class="title">Show Relative Dates In Changes Table</span>
+          <label class="title" for="relativeDateInChangeTable"
+            >Show Relative Dates In Changes Table</label
+          >
           <span class="value">
             <input
               id="relativeDateInChangeTable"
@@ -228,7 +235,9 @@
           </span>
         </section>
         <section>
-          <span class="title">Show size bars in file list</span>
+          <label for="showSizeBarsInFileList" class="title"
+            >Show size bars in file list</label
+          >
           <span class="value">
             <input
               id="showSizeBarsInFileList"
@@ -239,7 +248,9 @@
           </span>
         </section>
         <section>
-          <span class="title">Publish comments on push</span>
+          <label for="publishCommentsOnPush" class="title"
+            >Publish comments on push</label
+          >
           <span class="value">
             <input
               id="publishCommentsOnPush"
@@ -250,8 +261,8 @@
           </span>
         </section>
         <section>
-          <span class="title"
-            >Set new changes to "work in progress" by default</span
+          <label for="workInProgressByDefault" class="title"
+            >Set new changes to "work in progress" by default</label
           >
           <span class="value">
             <input
@@ -263,9 +274,9 @@
           </span>
         </section>
         <section>
-          <span class="title">
+          <label for="insertSignedOff" class="title">
             Insert Signed-off-by Footer For Inline Edit Changes
-          </span>
+          </label>
           <span class="value">
             <input
               id="insertSignedOff"
@@ -340,6 +351,7 @@
       <fieldset id="changeTableColumns">
         <gr-change-table-editor
           show-number="{{_showNumber}}"
+          server-config="[[_serverConfig]]"
           displayed-columns="{{_localChangeTableColumns}}"
         >
         </gr-change-table-editor>
@@ -550,7 +562,6 @@
         </table>
       </fieldset>
       <gr-endpoint-decorator name="settings-screen"> </gr-endpoint-decorator>
-    </main>
+    </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
index 0535e15..79789bb 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
@@ -19,7 +19,8 @@
 import {getComputedStyleValue} from '../../../utils/dom-util.js';
 import './gr-settings-view.js';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritView} from '../../../services/router/router-model.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-settings-view');
 const blankFixture = fixtureFromElement('div');
@@ -51,7 +52,7 @@
   }
 
   function stubAddAccountEmail(statusCode) {
-    return sinon.stub(element.$.restAPI, 'addAccountEmail').callsFake(
+    return stubRestApi('addAccountEmail').callsFake(
         () => Promise.resolve({status: statusCode}));
   }
 
@@ -82,17 +83,10 @@
     };
     config = {auth: {editable_account_fields: []}};
 
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-      getAccount() { return Promise.resolve(account); },
-      getPreferences() { return Promise.resolve(preferences); },
-      getWatchedProjects() {
-        return Promise.resolve([]);
-      },
-      getAccountEmails() { return Promise.resolve(); },
-      getConfig() { return Promise.resolve(config); },
-      getAccountGroups() { return Promise.resolve([]); },
-    });
+    stubRestApi('getAccount').returns(Promise.resolve(account));
+    stubRestApi('getPreferences').returns(Promise.resolve(preferences));
+    stubRestApi('getAccountEmails').returns(Promise.resolve());
+    stubRestApi('getConfig').returns(Promise.resolve(config));
     element = basicFixture.instantiate();
 
     // Allow the element to render.
@@ -179,13 +173,11 @@
     assert.isTrue(element._prefsChanged);
     assert.isFalse(element._menuChanged);
 
-    stub('gr-rest-api-interface', {
-      savePreferences(prefs) {
-        assert.equal(prefs.diff_view, 'SIDE_BY_SIDE');
-        assertMenusEqual(prefs.my, preferences.my);
-        assert.equal(prefs.publish_comments_on_push, true);
-        return Promise.resolve();
-      },
+    stubRestApi('savePreferences').callsFake(prefs => {
+      assert.equal(prefs.diff_view, 'SIDE_BY_SIDE');
+      assertMenusEqual(prefs.my, preferences.my);
+      assert.equal(prefs.publish_comments_on_push, true);
+      return Promise.resolve();
     });
 
     // Save the change.
@@ -204,11 +196,9 @@
     assert.isFalse(element._menuChanged);
     assert.isTrue(element._prefsChanged);
 
-    stub('gr-rest-api-interface', {
-      savePreferences(prefs) {
-        assert.equal(prefs.publish_comments_on_push, true);
-        return Promise.resolve();
-      },
+    stubRestApi('savePreferences').callsFake(prefs => {
+      assert.equal(prefs.publish_comments_on_push, true);
+      return Promise.resolve();
     });
 
     // Save the change.
@@ -228,11 +218,9 @@
     assert.isFalse(element._menuChanged);
     assert.isTrue(element._prefsChanged);
 
-    stub('gr-rest-api-interface', {
-      savePreferences(prefs) {
-        assert.equal(prefs.work_in_progress_by_default, true);
-        return Promise.resolve();
-      },
+    stubRestApi('savePreferences').callsFake(prefs => {
+      assert.equal(prefs.work_in_progress_by_default, true);
+      return Promise.resolve();
     });
 
     // Save the change.
@@ -263,11 +251,9 @@
     assert.isTrue(element._menuChanged);
     assert.isFalse(element._prefsChanged);
 
-    stub('gr-rest-api-interface', {
-      savePreferences(prefs) {
-        assertMenusEqual(prefs.my, element._localMenu);
-        return Promise.resolve();
-      },
+    stubRestApi('savePreferences').callsFake(prefs => {
+      assertMenusEqual(prefs.my, element._localMenu);
+      return Promise.resolve();
     });
 
     element._handleSaveMenu().then(() => {
@@ -343,7 +329,7 @@
   test('emails are loaded without emailToken', () => {
     sinon.stub(element.$.emailEditor, 'loadData');
     element.params = {};
-    element.attached();
+    element.connectedCallback();
     assert.isTrue(element.$.emailEditor.loadData.calledOnce);
   });
 
@@ -351,9 +337,7 @@
     let newColumns = ['Owner', 'Project', 'Branch'];
     element._localChangeTableColumns = newColumns.slice(0);
     element._showNumber = false;
-    const cloneStub = sinon.stub(element, '_cloneChangeTableColumns');
     element._handleSaveChangeTable();
-    assert.isTrue(cloneStub.calledOnce);
     assert.deepEqual(element.prefs.change_table, newColumns);
     assert.isNotOk(element.prefs.legacycid_in_change_table);
 
@@ -361,7 +345,6 @@
     element._localChangeTableColumns = newColumns;
     element._showNumber = true;
     element._handleSaveChangeTable();
-    assert.isTrue(cloneStub.calledTwice);
     assert.deepEqual(element.prefs.change_table, newColumns);
     assert.isTrue(element.prefs.legacycid_in_change_table);
   });
@@ -375,9 +358,7 @@
       ],
     };
 
-    stub('gr-rest-api-interface', {
-      getDefaultPreferences() { return Promise.resolve(originalMenu); },
-    });
+    stubRestApi('getDefaultPreferences').returns(Promise.resolve(originalMenu));
 
     const updatedMenu = [
       {url: '/first/url', name: 'first name', target: '_blank'},
@@ -477,22 +458,19 @@
 
   suite('when email verification token is provided', () => {
     let resolveConfirm;
+    let confirmEmailStub;
 
     setup(() => {
       sinon.stub(element.$.emailEditor, 'loadData');
-      sinon.stub(
-          element.$.restAPI,
-          'confirmEmail')
-          .callsFake(
-              () => new Promise(
-                  resolve => { resolveConfirm = resolve; }));
+      confirmEmailStub = stubRestApi('confirmEmail').returns(
+          new Promise(resolve => { resolveConfirm = resolve; }));
       element.params = {view: GerritView.SETTINGS, emailToken: 'foo'};
-      element.attached();
+      element.connectedCallback();
     });
 
     test('it is used to confirm email via rest API', () => {
-      assert.isTrue(element.$.restAPI.confirmEmail.calledOnce);
-      assert.isTrue(element.$.restAPI.confirmEmail.calledWith('foo'));
+      assert.isTrue(confirmEmailStub.calledOnce);
+      assert.isTrue(confirmEmailStub.calledWith('foo'));
     });
 
     test('emails are not loaded initially', () => {
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
index 507caef..c1f347f 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
@@ -19,11 +19,8 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-ssh-editor_html';
 import {property, customElement} from '@polymer/decorators';
@@ -31,11 +28,10 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
 export interface GrSshEditor {
   $: {
-    restAPI: RestApiService & Element;
     addButton: GrButton;
     newKey: IronAutogrowTextareaElement;
     viewKeyOverlay: GrOverlay;
@@ -48,9 +44,7 @@
   }
 }
 @customElement('gr-ssh-editor')
-export class GrSshEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrSshEditor extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -70,8 +64,10 @@
   @property({type: Array})
   _keysToRemove: SshKeyInfo[] = [];
 
+  private readonly restApiService = appContext.restApiService;
+
   loadData() {
-    return this.$.restAPI.getAccountSSHKeys().then(keys => {
+    return this.restApiService.getAccountSSHKeys().then(keys => {
       if (!keys) return;
       this._keys = keys;
     });
@@ -79,7 +75,7 @@
 
   save() {
     const promises = this._keysToRemove.map(key =>
-      this.$.restAPI.deleteAccountSSHKey(`${key.seq}`)
+      this.restApiService.deleteAccountSSHKey(`${key.seq}`)
     );
     return Promise.all(promises).then(() => {
       this._keysToRemove = [];
@@ -113,7 +109,7 @@
   _handleAddKey() {
     this.$.addButton.disabled = true;
     this.$.newKey.disabled = true;
-    return this.$.restAPI
+    return this.restApiService
       .addAccountSSHKey(this._newKey.trim())
       .then(key => {
         this.$.newKey.disabled = false;
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
index 96f770a..0bee1d3 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
@@ -139,5 +139,4 @@
       >
     </fieldset>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js
index 29ba692..8f99baa 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-ssh-editor.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-ssh-editor');
 
@@ -41,9 +42,7 @@
       valid: true,
     }];
 
-    stub('gr-rest-api-interface', {
-      getAccountSSHKeys() { return Promise.resolve(keys); },
-    });
+    stubRestApi('getAccountSSHKeys').returns(Promise.resolve(keys));
 
     element = basicFixture.instantiate();
 
@@ -65,7 +64,7 @@
   test('remove key', done => {
     const lastKey = keys[1];
 
-    const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountSSHKey')
+    const saveStub = stubRestApi('deleteAccountSSHKey')
         .callsFake(() => Promise.resolve());
 
     assert.equal(element._keysToRemove.length, 0);
@@ -116,7 +115,8 @@
       valid: true,
     };
 
-    const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey').callsFake(
+    const addStub = stubRestApi(
+        'addAccountSSHKey').callsFake(
         () => Promise.resolve(newKeyObject));
 
     element._newKey = newKeyString;
@@ -141,7 +141,8 @@
   test('add invalid key', done => {
     const newKeyString = 'not even close to valid';
 
-    const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey').callsFake(
+    const addStub = stubRestApi(
+        'addAccountSSHKey').callsFake(
         () => Promise.reject(new Error('error')));
 
     element._newKey = newKeyString;
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index 15f9c6b..6178e7a 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -17,12 +17,9 @@
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-watched-projects-editor_html';
 import {customElement, property} from '@polymer/decorators';
@@ -31,9 +28,9 @@
   GrAutocomplete,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {ProjectWatchInfo} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
 
 const NOTIFICATION_TYPES = [
   {name: 'Changes', key: 'notify_new_changes'},
@@ -45,15 +42,12 @@
 
 export interface GrWatchedProjectsEditor {
   $: {
-    restAPI: RestApiService & Element;
     newFilter: HTMLInputElement;
     newProject: GrAutocomplete;
   };
 }
 @customElement('gr-watched-projects-editor')
-export class GrWatchedProjectsEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrWatchedProjectsEditor extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -70,21 +64,23 @@
   @property({type: Object})
   _query?: AutocompleteQuery;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = input => this._getProjectSuggestions(input);
   }
 
   loadData() {
-    return this.$.restAPI.getWatchedProjects().then(projs => {
+    return this.restApiService.getWatchedProjects().then(projs => {
       this._projects = projs;
     });
   }
 
   save() {
-    let deletePromise;
+    let deletePromise: Promise<Response | undefined>;
     if (this._projectsToRemove.length) {
-      deletePromise = this.$.restAPI.deleteWatchedProjects(
+      deletePromise = this.restApiService.deleteWatchedProjects(
         this._projectsToRemove
       );
     } else {
@@ -94,7 +90,7 @@
     return deletePromise
       .then(() => {
         if (this._projects) {
-          return this.$.restAPI.saveWatchedProjects(this._projects);
+          return this.restApiService.saveWatchedProjects(this._projects);
         } else {
           return Promise.resolve(undefined);
         }
@@ -119,16 +115,10 @@
   }
 
   _getProjectSuggestions(input: string) {
-    return this.$.restAPI.getSuggestedProjects(input).then(response => {
+    return this.restApiService.getSuggestedProjects(input).then(response => {
       const projects: AutocompleteSuggestion[] = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        projects.push({
-          name: key,
-          value: response[key].id,
-        });
+      for (const [name, project] of Object.entries(response ?? {})) {
+        projects.push({name, value: project.id});
       }
       return projects;
     });
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
index e3a90a2..edc8fb2 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
@@ -120,5 +120,4 @@
       </tfoot>
     </table>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
index 2fd8900..aac8995 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-watched-projects-editor.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-watched-projects-editor');
 
@@ -44,21 +45,17 @@
       },
     ];
 
-    stub('gr-rest-api-interface', {
-      getSuggestedProjects(input) {
-        if (input.startsWith('th')) {
-          return Promise.resolve({'the project': {
-            id: 'the project',
-            state: 'ACTIVE',
-            web_links: [],
-          }});
-        } else {
-          return Promise.resolve({});
-        }
-      },
-      getWatchedProjects() {
-        return Promise.resolve(projects);
-      },
+    stubRestApi('getWatchedProjects').returns(Promise.resolve(projects));
+    stubRestApi('getSuggestedProjects').callsFake(input => {
+      if (input.startsWith('th')) {
+        return Promise.resolve({'the project': {
+          id: 'the project',
+          state: 'ACTIVE',
+          web_links: [],
+        }});
+      } else {
+        return Promise.resolve({});
+      }
     });
 
     element = basicFixture.instantiate();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index 9623442..dab778b 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,25 +17,15 @@
 import '../gr-account-link/gr-account-link';
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 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 {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
-export interface GrAccountChip {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-account-chip')
-export class GrAccountChip extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAccountChip extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -94,6 +84,8 @@
   @property({type: Boolean})
   transparentBackground = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   ready() {
     super.ready();
@@ -118,7 +110,7 @@
   }
 
   _getHasAvatars() {
-    return this.$.restAPI
+    return this.restApiService
       .getConfig()
       .then(cfg =>
         Promise.resolve(!!(cfg && cfg.plugin && cfg.plugin.has_avatars))
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
index 991104f..e5efebb 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
@@ -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;
       overflow: hidden;
@@ -42,7 +42,10 @@
     }
     gr-button.remove {
       --gr-remove-button-style: {
-        border: 0;
+        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;
@@ -58,7 +61,6 @@
     gr-button.remove:focus {
       --gr-button: {
         @apply --gr-remove-button-style;
-        color: #333;
       }
     }
     gr-button.remove {
@@ -109,5 +111,4 @@
       <iron-icon icon="gr-icons:close"></iron-icon>
     </gr-button>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
index 925480f..d793a8d 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
@@ -16,9 +16,6 @@
  */
 import '../../../styles/shared-styles';
 import '../gr-autocomplete/gr-autocomplete';
-import '../gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-account-entry_html';
 import {customElement, property} from '@polymer/decorators';
@@ -34,9 +31,7 @@
  * and/or group with autocomplete support.
  */
 @customElement('gr-account-entry')
-export class GrAccountEntry extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAccountEntry extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
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 5852135..f6bdf33 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -18,29 +18,21 @@
 import '../../../styles/shared-styles';
 import '../gr-avatar/gr-avatar';
 import '../gr-hovercard-account/gr-hovercard-account';
-import '../gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-account-label_html';
 import {appContext} from '../../../services/app-context';
 import {getDisplayName} from '../../../utils/display-name-util';
-import {isServiceUser} from '../../../utils/account-util';
+import {isSelf, isServiceUser} from '../../../utils/account-util';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {fireEvent} from '../../../utils/event-util';
+import {isInvolved} from '../../../utils/change-util';
+import {ShowAlertEventDetail} from '../../../types/events';
 
-export interface GrAccountLabel {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-account-label')
-export class GrAccountLabel extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAccountLabel extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -104,8 +96,16 @@
   @property({type: Object})
   _config?: ServerInfo;
 
+  @property({type: Boolean, reflectToAttribute: true})
+  selected = false;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  deselected = false;
+
   reporting: ReportingService;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this.reporting = appContext.reportingService;
@@ -114,10 +114,10 @@
   /** @override */
   ready() {
     super.ready();
-    this.$.restAPI.getConfig().then(config => {
+    this.restApiService.getConfig().then(config => {
       this._config = config;
     });
-    this.$.restAPI.getAccount().then(account => {
+    this.restApiService.getAccount().then(account => {
       this._selfAccount = account;
     });
     this.addEventListener('attention-set-updated', () => {
@@ -206,12 +206,13 @@
   }
 
   _handleRemoveAttentionClick(e: MouseEvent) {
+    if (this.selected) return;
     e.preventDefault();
     e.stopPropagation();
     if (!this.account._account_id) return;
 
     this.dispatchEvent(
-      new CustomEvent('show-alert', {
+      new CustomEvent<ShowAlertEventDetail>('show-alert', {
         detail: {
           message: 'Saving attention set update ...',
           dismissOnNavigation: true,
@@ -234,16 +235,14 @@
       'attention-icon-remove',
       this._reportingDetails()
     );
-    this.$.restAPI
+    this.restApiService
       .removeFromAttentionSet(
         this.change._number,
         this.account._account_id,
         reason
       )
       .then(() => {
-        this.dispatchEvent(
-          new CustomEvent('hide-alert', {bubbles: true, composed: true})
-        );
+        fireEvent(this, 'hide-alert');
       });
   }
 
@@ -268,15 +267,43 @@
     };
   }
 
+  _computeAttentionButtonEnabled(
+    config: ServerInfo | undefined,
+    highlight: boolean,
+    account: AccountInfo,
+    change: ChangeInfo,
+    selfAccount: AccountInfo,
+    selected: boolean
+  ) {
+    if (selected) return true;
+    return (
+      this._hasUnforcedAttention(config, highlight, account, change) &&
+      (isInvolved(change, selfAccount) || isSelf(account, selfAccount))
+    );
+  }
+
   _computeAttentionIconTitle(
     config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo
+    change: ChangeInfo,
+    selfAccount: AccountInfo,
+    force: boolean,
+    selected: boolean
   ) {
-    return this._hasUnforcedAttention(config, highlight, account, change)
+    const enabled = this._computeAttentionButtonEnabled(
+      config,
+      highlight,
+      account,
+      change,
+      selfAccount,
+      selected
+    );
+    return enabled
       ? 'Click to remove the user from the attention set'
-      : 'Disabled. Use "Modify" to make changes.';
+      : force
+      ? 'Disabled. Use "Modify" to make changes.'
+      : 'Disabled. Only involved users can change.';
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
index 1d8b13e..ff40aeb 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
@@ -17,17 +17,14 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
-  <style include="shared-styles">
+  <style>
     :host {
       display: inline-block;
       vertical-align: top;
       position: relative;
       border-radius: var(--label-border-radius);
-      max-width: var(--account-max-length, 200px);
       box-sizing: border-box;
       white-space: nowrap;
-      overflow: hidden;
-      text-overflow: ellipsis;
       padding: 0 var(--account-label-padding-horizontal, 0);
     }
     /* If the first element is the avatar, then we cancel the left padding, so
@@ -75,16 +72,26 @@
       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);
     }
@@ -109,9 +116,9 @@
         link=""
         aria-label="Remove user from attention set"
         on-click="_handleRemoveAttentionClick"
-        disabled="[[!_hasUnforcedAttention(_config, highlightAttention, account, change)]]"
-        has-tooltip="[[_hasUnforcedAttention(_config, highlightAttention, account, change)]]"
-        title="[[_computeAttentionIconTitle(_config, highlightAttention, account, change)]]"
+        disabled="[[!_computeAttentionButtonEnabled(_config, highlightAttention, account, change, _selfAccount, selected)]]"
+        has-tooltip="[[_computeAttentionButtonEnabled(_config, highlightAttention, account, change, _selfAccount, false)]]"
+        title="[[_computeAttentionIconTitle(_config, highlightAttention, account, change, _selfAccount, forceAttention, selected)]]"
         ><iron-icon class="attention" icon="gr-icons:attention"></iron-icon>
       </gr-button>
     </template>
@@ -132,5 +139,4 @@
       </template>
     </span>
   </span>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
index 2e54db2..f37aa01 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-account-label.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-account-label');
 
@@ -28,10 +29,7 @@
   }
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getLoggedIn() { return Promise.resolve(false); },
-    });
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     element = basicFixture.instantiate();
     element._config = {
       user: {
@@ -82,16 +80,21 @@
   });
 
   suite('attention set', () => {
-    setup(() => {
+    setup(async () => {
+      const kermit = createAccount('kermit', 31);
       element.highlightAttention = true;
       element._config = {
         change: {enable_attention_set: true},
         user: {anonymous_coward_name: 'Anonymous Coward'},
       };
-      element._selfAccount = createAccount('kermit', 31);
+      element._selfAccount = kermit;
       element.account = createAccount('ernie', 42);
-      element.change = {attention_set: {42: {}}};
-      flush();
+      element.change = {
+        attention_set: {42: {}},
+        owner: kermit,
+        reviewers: {},
+      };
+      await flush();
     });
 
     test('show attention button', () => {
@@ -99,7 +102,8 @@
     });
 
     test('tap attention button', () => {
-      const apiStub = sinon.stub(element.$.restAPI, 'removeFromAttentionSet')
+      const apiStub = stubRestApi(
+          'removeFromAttentionSet')
           .callsFake(() => Promise.resolve());
       const button = element.shadowRoot.querySelector('#attentionButton');
       assert.ok(button);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
index be54f4e..5c4b76a 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
@@ -17,8 +17,6 @@
 
 import '../gr-account-label/gr-account-label';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-account-link_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
@@ -26,9 +24,7 @@
 import {AccountInfo, ChangeInfo} from '../../../types/common';
 
 @customElement('gr-account-link')
-class GrAccountLink extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrAccountLink extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
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
index be4db01..98a6e77 100644
--- 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
@@ -17,7 +17,7 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
-  <style include="shared-styles">
+  <style>
     :host {
       display: inline-block;
       vertical-align: top;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js
index 554953e..34fef2f 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js
@@ -25,9 +25,6 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
     element = basicFixture.instantiate();
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index c5e71fc..1e54f69 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
@@ -17,8 +17,6 @@
 import '../gr-account-chip/gr-account-chip';
 import '../gr-account-entry/gr-account-entry';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-account-list_html';
 import {appContext} from '../../../services/app-context';
@@ -38,6 +36,7 @@
 import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {PaperInputElementExt} from '../../../types/types';
+import {fireAlert} from '../../../utils/event-util';
 
 const VALID_EMAIL_ALERT = 'Please input a valid email.';
 
@@ -114,9 +113,7 @@
 }
 
 @customElement('gr-account-list')
-export class GrAccountList extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAccountList extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -191,11 +188,6 @@
     super();
     this.reporting = appContext.reportingService;
     this._querySuggestions = input => this._getSuggestions(input);
-  }
-
-  /** @override */
-  created() {
-    super.created();
     this.addEventListener('remove', e =>
       this._handleRemove(e as CustomEvent<{account: AccountInput}>)
     );
@@ -256,13 +248,7 @@
         // Repopulate the input with what the user tried to enter and have
         // a toast tell them why they can't enter it.
         this.$.entry.setText(item);
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: VALID_EMAIL_ALERT},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(this, VALID_EMAIL_ALERT);
         return false;
       } else {
         const account = {email: item, _pendingAdd: true};
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
index 3acba5b..20e8672 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
@@ -60,9 +60,6 @@
     existingAccount1 = makeAccount();
     existingAccount2 = makeAccount();
 
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
     element = basicFixture.instantiate();
     element.accounts = [existingAccount1, existingAccount2];
     suggestionsProvider = new MockSuggestionsProvider();
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
index e5806f0..4d2576a 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -16,8 +16,6 @@
  */
 import '../gr-button/gr-button';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-alert_html';
 import {getRootElement} from '../../../scripts/rootElement';
@@ -31,9 +29,7 @@
 }
 
 @customElement('gr-alert')
-export class GrAlert extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAlert extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -62,6 +58,9 @@
   @property({type: Boolean})
   _hideActionButton?: boolean;
 
+  @property({type: Boolean})
+  showDismiss = false;
+
   @property()
   _boundTransitionEndHandler?: (
     this: HTMLElement,
@@ -72,21 +71,21 @@
   _actionCallback?: () => void;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._boundTransitionEndHandler = () => this._handleTransitionEnd();
     this.addEventListener('transitionend', this._boundTransitionEndHandler);
   }
 
   /** @override */
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     if (this._boundTransitionEndHandler) {
       this.removeEventListener(
         'transitionend',
         this._boundTransitionEndHandler
       );
     }
+    super.disconnectedCallback();
   }
 
   show(text: string, actionText?: string, actionCallback?: () => void) {
@@ -105,6 +104,10 @@
     }
   }
 
+  _handleDismissTap() {
+    this.hide();
+  }
+
   _hasZeroTransitionDuration() {
     const style = window.getComputedStyle(this);
     // transitionDuration is always given in seconds.
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
index d2aed40..bc517a8 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
@@ -27,7 +27,7 @@
       bottom: 1.25rem;
       border-radius: var(--border-radius);
       box-shadow: var(--elevation-level-2);
-      color: var(--view-background-color);
+      color: var(--tooltip-text-color);
       left: 1.25rem;
       position: fixed;
       transform: translateY(5rem);
@@ -74,6 +74,10 @@
       hidden$="[[_hideActionButton]]"
       on-click="_handleActionTap"
       >[[actionText]]</gr-button
+    ><template is="dom-if" if="[[showDismiss]]"
+      ><gr-button link="" class="action" on-click="_handleDismissTap"
+        >Dismiss</gr-button
+      ></template
     >
   </div>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index 451bdfa..fdc72ce 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
@@ -18,19 +18,17 @@
 import '../gr-cursor-manager/gr-cursor-manager';
 import '../../../styles/shared-styles';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-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';
 
-// TODO(TS): Update once GrCursorManager is upated
 export interface GrAutocompleteDropdown {
   $: {
-    cursor: any;
     suggestions: Element;
   };
 }
@@ -49,14 +47,9 @@
   value?: string;
 }
 
-/**
- * @extends PolymerElement
- */
 @customElement('gr-autocomplete-dropdown')
 export class GrAutocompleteDropdown extends IronFitMixin(
-  KeyboardShortcutMixin(
-    GestureEventListeners(LegacyElementMixin(PolymerElement))
-  ),
+  KeyboardShortcutMixin(PolymerElement),
   IronFitBehavior as IronFitBehavior
 ) {
   static get template() {
@@ -90,9 +83,6 @@
   @property({type: Array})
   suggestions: Item[] = [];
 
-  @property({type: Array})
-  _suggestionEls: Element[] = [];
-
   get keyBindings() {
     return {
       up: '_handleUp',
@@ -103,6 +93,20 @@
     };
   }
 
+  private cursor = new GrCursorManager();
+
+  constructor() {
+    super();
+    this.cursor.cursorTargetClass = 'selected';
+    this.cursor.focusOnMove = true;
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    this.cursor.unsetCursor();
+    super.disconnectedCallback();
+  }
+
   close() {
     this.isHidden = true;
   }
@@ -115,7 +119,7 @@
   }
 
   getCurrentText() {
-    return this.getCursorTarget().dataset['value'];
+    return this.getCursorTarget()?.dataset['value'] || '';
   }
 
   _handleUp(e: Event) {
@@ -136,13 +140,13 @@
 
   cursorDown() {
     if (!this.isHidden) {
-      this.$.cursor.next();
+      this.cursor.next();
     }
   }
 
   cursorUp() {
     if (!this.isHidden) {
-      this.$.cursor.previous();
+      this.cursor.previous();
     }
   }
 
@@ -153,7 +157,7 @@
       new CustomEvent('item-selected', {
         detail: {
           trigger: 'tab',
-          selected: this.$.cursor.target,
+          selected: this.cursor.target,
         },
         composed: true,
         bubbles: true,
@@ -168,7 +172,7 @@
       new CustomEvent('item-selected', {
         detail: {
           trigger: 'enter',
-          selected: this.$.cursor.target,
+          selected: this.cursor.target,
         },
         composed: true,
         bubbles: true,
@@ -204,16 +208,11 @@
   }
 
   _fireClose() {
-    this.dispatchEvent(
-      new CustomEvent('dropdown-closed', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'dropdown-closed');
   }
 
   getCursorTarget() {
-    return this.$.cursor.target;
+    return this.cursor.target;
   }
 
   @observe('suggestions')
@@ -221,18 +220,23 @@
     if (this.suggestions.length > 0) {
       if (!this.isHidden) {
         flush();
-        this._suggestionEls = Array.from(
+        this.cursor.stops = Array.from(
           this.$.suggestions.querySelectorAll('li')
         );
         this._resetCursorIndex();
       }
     } else {
-      this._suggestionEls = [];
+      this.cursor.stops = [];
     }
   }
 
+  @observe('index')
+  _setIndex() {
+    this.cursor.index = this.index || -1;
+  }
+
   _resetCursorIndex() {
-    this.$.cursor.setCursorAtIndex(0);
+    this.cursor.setCursorAtIndex(0);
   }
 
   _computeLabelClass(item: Item) {
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.ts
index d3d2481..b86e8ec 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.ts
@@ -91,12 +91,4 @@
       </template>
     </ul>
   </div>
-  <gr-cursor-manager
-    id="cursor"
-    index="{{index}}"
-    cursor-target-class="selected"
-    scroll-mode="never"
-    focus-on-move=""
-    stops="[[_suggestionEls]]"
-  ></gr-cursor-manager>
 `;
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.js
index ad06649..200fddc 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.js
@@ -55,7 +55,7 @@
     element.addEventListener('item-selected', itemSelectedStub);
     MockInteractions.pressAndReleaseKeyOn(element, 9);
     assert.isTrue(handleTabSpy.called);
-    assert.equal(element.$.cursor.index, 0);
+    assert.equal(element.cursor.index, 0);
     assert.isTrue(itemSelectedStub.called);
     assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
       trigger: 'tab',
@@ -69,7 +69,7 @@
     element.addEventListener('item-selected', itemSelectedStub);
     MockInteractions.pressAndReleaseKeyOn(element, 13);
     assert.isTrue(handleEnterSpy.called);
-    assert.equal(element.$.cursor.index, 0);
+    assert.equal(element.cursor.index, 0);
     assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
       trigger: 'enter',
       selected: element.getCursorTarget(),
@@ -78,28 +78,28 @@
 
   test('down key', () => {
     element.isHidden = true;
-    const nextSpy = sinon.spy(element.$.cursor, 'next');
+    const nextSpy = sinon.spy(element.cursor, 'next');
     MockInteractions.pressAndReleaseKeyOn(element, 40);
     assert.isFalse(nextSpy.called);
-    assert.equal(element.$.cursor.index, 0);
+    assert.equal(element.cursor.index, 0);
     element.isHidden = false;
     MockInteractions.pressAndReleaseKeyOn(element, 40);
     assert.isTrue(nextSpy.called);
-    assert.equal(element.$.cursor.index, 1);
+    assert.equal(element.cursor.index, 1);
   });
 
   test('up key', () => {
     element.isHidden = true;
-    const prevSpy = sinon.spy(element.$.cursor, 'previous');
+    const prevSpy = sinon.spy(element.cursor, 'previous');
     MockInteractions.pressAndReleaseKeyOn(element, 38);
     assert.isFalse(prevSpy.called);
-    assert.equal(element.$.cursor.index, 0);
+    assert.equal(element.cursor.index, 0);
     element.isHidden = false;
-    element.$.cursor.setCursorAtIndex(1);
-    assert.equal(element.$.cursor.index, 1);
+    element.cursor.setCursorAtIndex(1);
+    assert.equal(element.cursor.index, 1);
     MockInteractions.pressAndReleaseKeyOn(element, 38);
     assert.isTrue(prevSpy.called);
-    assert.equal(element.$.cursor.index, 0);
+    assert.equal(element.cursor.index, 0);
   });
 
   test('tapping selects item', () => {
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 45f30f5..1ff5350 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -20,16 +20,15 @@
 import '../gr-icons/gr-icons';
 import '../../../styles/shared-styles';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-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 {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
 import {PaperInputElementExt} from '../../../types/types';
 import {CustomKeyboardEvent} from '../../../types/events';
+import {fireEvent} from '../../../utils/event-util';
+import {debounce, DelayedTask} from '../../../utils/async-util';
 
 const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
 const DEBOUNCE_WAIT_MS = 200;
@@ -38,7 +37,6 @@
   $: {
     input: PaperInputElementExt;
     suggestions: GrAutocompleteDropdown;
-    cursor: GrCursorManager;
   };
 }
 
@@ -55,9 +53,7 @@
 export interface AutocompleteSuggestion {
   name?: string;
   label?: string;
-  // TODO(TS): this value can be string or arbitrary object (in gr-create-repo-dialog)
-  // probably should limit it to string only as it seems not used
-  value?: any;
+  value?: string;
   text?: string;
 }
 
@@ -65,14 +61,10 @@
   value: string;
 }
 
-export type AutocompleteCommitEvent = CustomEvent<
-  AutocompleteCommitEventDetail
->;
+export type AutocompleteCommitEvent = CustomEvent<AutocompleteCommitEventDetail>;
 
 @customElement('gr-autocomplete')
-export class GrAutocomplete extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrAutocomplete extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -202,6 +194,8 @@
   @property({type: Object})
   _selected: HTMLElement | null = null;
 
+  private updateSuggestionsTask?: DelayedTask;
+
   get _nativeInput() {
     // In Polymer 2 inputElement isn't nativeInput anymore
     return (this.$.input.$.nativeInput ||
@@ -209,16 +203,16 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
-    this.listen(document.body, 'click', '_handleBodyClick');
+  connectedCallback() {
+    super.connectedCallback();
+    document.addEventListener('click', this.handleBodyClick);
   }
 
   /** @override */
-  detached() {
-    super.detached();
-    this.unlisten(document.body, 'click', '_handleBodyClick');
-    this.cancelDebouncer('update-suggestions');
+  disconnectedCallback() {
+    document.removeEventListener('click', this.handleBodyClick);
+    this.updateSuggestionsTask?.cancel();
+    super.disconnectedCallback();
   }
 
   get focusStart() {
@@ -331,7 +325,11 @@
     if (noDebounce) {
       update();
     } else {
-      this.debounce('update-suggestions', update, DEBOUNCE_WAIT_MS);
+      this.updateSuggestionsTask = debounce(
+        this.updateSuggestionsTask,
+        update,
+        DEBOUNCE_WAIT_MS
+      );
     }
   }
 
@@ -406,12 +404,7 @@
     if (this._suggestions.length) {
       this.set('_suggestions', []);
     } else {
-      this.dispatchEvent(
-        new CustomEvent('cancel', {
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireEvent(this, 'cancel');
     }
   }
 
@@ -449,7 +442,7 @@
     }
   }
 
-  _handleBodyClick(e: Event) {
+  private readonly handleBodyClick = (e: Event) => {
     const eventPath = e.composedPath();
     if (!eventPath) return;
     for (let i = 0; i < eventPath.length; i++) {
@@ -458,7 +451,7 @@
       }
     }
     this._focused = false;
-  }
+  };
 
   /**
    * Commits the suggestion, optionally firing the commit event.
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
index 329265e..d72007e 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
@@ -46,7 +46,7 @@
     ]));
     element.query = queryStub;
     assert.isTrue(element.$.suggestions.isHidden);
-    assert.equal(element.$.suggestions.$.cursor.index, -1);
+    assert.equal(element.$.suggestions.cursor.index, -1);
 
     focusOnInput(element);
     element.text = 'blah';
@@ -64,7 +64,7 @@
         assert.equal(suggestions[i].innerText.trim(), 'blah ' + i);
       }
 
-      assert.notEqual(element.$.suggestions.$.cursor.index, -1);
+      assert.notEqual(element.$.suggestions.cursor.index, -1);
     });
   });
 
@@ -121,7 +121,7 @@
     element.query = queryStub;
 
     assert.isTrue(element.$.suggestions.isHidden);
-    assert.equal(element.$.suggestions.$.cursor.index, -1);
+    assert.equal(element.$.suggestions.cursor.index, -1);
     element._focused = true;
     element.text = 'blah';
 
@@ -131,21 +131,21 @@
       const commitHandler = sinon.spy();
       element.addEventListener('commit', commitHandler);
 
-      assert.equal(element.$.suggestions.$.cursor.index, 0);
+      assert.equal(element.$.suggestions.cursor.index, 0);
 
       MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
           'down');
 
-      assert.equal(element.$.suggestions.$.cursor.index, 1);
+      assert.equal(element.$.suggestions.cursor.index, 1);
 
       MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
           'down');
 
-      assert.equal(element.$.suggestions.$.cursor.index, 2);
+      assert.equal(element.$.suggestions.cursor.index, 2);
 
       MockInteractions.pressAndReleaseKeyOn(element.$.input, 38, null, 'up');
 
-      assert.equal(element.$.suggestions.$.cursor.index, 1);
+      assert.equal(element.$.suggestions.cursor.index, 1);
 
       MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
           'enter');
@@ -215,19 +215,18 @@
   });
 
   test('noDebounce=false debounces the query', () => {
+    const clock = sinon.useFakeTimers();
     const queryStub = sinon.spy(() => Promise.resolve([]));
-    let callback;
-    const debounceStub = sinon.stub(element, 'debounce').callsFake(
-        (name, cb) => { callback = cb; });
     element.query = queryStub;
     element.noDebounce = false;
     focusOnInput(element);
     element.text = 'a';
+
+    // not called right away
     assert.isFalse(queryStub.called);
-    assert.isTrue(debounceStub.called);
-    assert.equal(debounceStub.lastCall.args[2], 200);
-    assert.isFunction(callback);
-    callback();
+
+    // but called after a while
+    clock.tick(1000);
     assert.isTrue(queryStub.called);
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
index 45bac9f..e30e995 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -15,27 +15,16 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import '../gr-js-api-interface/gr-js-api-interface';
-import '../gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 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 {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
-export interface GrAvatar {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-avatar')
-export class GrAvatar extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAvatar extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -49,9 +38,11 @@
   @property({type: Boolean})
   _hasAvatars = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     Promise.all([
       this._getConfig(),
       getPluginLoader().awaitPluginsLoaded(),
@@ -63,7 +54,7 @@
   }
 
   _getConfig() {
-    return this.$.restAPI.getConfig();
+    return this.restApiService.getConfig();
   }
 
   _accountChanged() {
@@ -94,6 +85,11 @@
       return '';
     }
     const avatars = account.avatars || [];
+    // if there is no avatar url in account, there is no avatar set on server,
+    // and request /avatar?s will be 404.
+    if (avatars.length === 0) {
+      return '';
+    }
     for (let i = 0; i < avatars.length; i++) {
       if (avatars[i].height === this.imageSize) {
         return avatars[i].url;
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
index 0d8e78f..6abf10c 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
@@ -17,13 +17,15 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
-  <style include="shared-styles">
+  <style>
+    :host([hidden]) {
+      display: none;
+    }
     :host {
       display: inline-block;
       border-radius: 50%;
       background-size: cover;
-      background-color: var(--avatar-background-color, #f1f2f3);
+      background-color: var(--avatar-background-color, var(--gray-background));
     }
   </style>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
index 261d59c..df8632f 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
@@ -18,35 +18,54 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-avatar.js';
 import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader.js';
+import {appContext} from '../../../services/app-context.js';
 
 const basicFixture = fixtureFromElement('gr-avatar');
 
 suite('gr-avatar tests', () => {
   let element;
+  const defaultAvatars = [
+    {
+      url: 'https://cdn.example.com/s12-p/photo.jpg',
+      height: 12,
+    },
+  ];
 
   setup(() => {
     element = basicFixture.instantiate();
   });
 
+  test('account without avatar', () => {
+    assert.equal(
+        element._buildAvatarURL({
+          _account_id: 123,
+        }),
+        '');
+  });
+
   test('methods', () => {
     assert.equal(
         element._buildAvatarURL({
           _account_id: 123,
+          avatars: defaultAvatars,
         }),
         '/accounts/123/avatar?s=16');
     assert.equal(
         element._buildAvatarURL({
           email: 'test@example.com',
+          avatars: defaultAvatars,
         }),
         '/accounts/test%40example.com/avatar?s=16');
     assert.equal(
         element._buildAvatarURL({
           name: 'John Doe',
+          avatars: defaultAvatars,
         }),
         '/accounts/John%20Doe/avatar?s=16');
     assert.equal(
         element._buildAvatarURL({
           username: 'John_Doe',
+          avatars: defaultAvatars,
         }),
         '/accounts/John_Doe/avatar?s=16');
     assert.equal(
@@ -84,9 +103,9 @@
 
   suite('config set', () => {
     setup(() => {
-      stub('gr-avatar', {
-        _getConfig: () => Promise.resolve({plugin: {has_avatars: true}}),
-      });
+      stub('gr-avatar', '_getConfig').callsFake(() =>
+        Promise.resolve({plugin: {has_avatars: true}})
+      );
       element = basicFixture.instantiate();
     });
 
@@ -96,7 +115,9 @@
       element.imageSize = 64;
       element.account = {
         _account_id: 123,
+        avatars: defaultAvatars,
       };
+      flush();
 
       assert.strictEqual(element.style.backgroundImage, '');
 
@@ -104,7 +125,7 @@
       getPluginLoader().loadPlugins([]);
 
       return Promise.all([
-        element.$.restAPI.getConfig(),
+        appContext.restApiService.getConfig(),
         getPluginLoader().awaitPluginsLoaded(),
       ]).then(() => {
         assert.isFalse(element.hasAttribute('hidden'));
@@ -120,9 +141,9 @@
     let element;
 
     setup(() => {
-      stub('gr-avatar', {
-        _getConfig: () => Promise.resolve({plugin: {has_avatars: true}}),
-      });
+      stub('gr-avatar', '_getConfig').callsFake(() =>
+        Promise.resolve({plugin: {has_avatars: true}})
+      );
 
       element = basicFixture.instantiate();
     });
@@ -134,7 +155,7 @@
       getPluginLoader().loadPlugins([]);
 
       return Promise.all([
-        element.$.restAPI.getConfig(),
+        appContext.restApiService.getConfig(),
         getPluginLoader().awaitPluginsLoaded(),
       ]).then(() => {
         assert.isTrue(element.hasAttribute('hidden'));
@@ -148,9 +169,7 @@
     let element;
 
     setup(() => {
-      stub('gr-avatar', {
-        _getConfig: () => Promise.resolve({}),
-      });
+      stub('gr-avatar', '_getConfig').callsFake(() => Promise.resolve({}));
 
       element = basicFixture.instantiate();
     });
@@ -162,12 +181,13 @@
       element.imageSize = 64;
       element.account = {
         _account_id: 123,
+        avatars: defaultAvatars,
       };
       // Emulate plugins loaded.
       getPluginLoader().loadPlugins([]);
 
       return Promise.all([
-        element.$.restAPI.getConfig(),
+        appContext.restApiService.getConfig(),
         getPluginLoader().awaitPluginsLoaded(),
       ]).then(() => {
         assert.isTrue(element.hasAttribute('hidden'));
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index 7a6ce2c..6a7c05b 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -16,8 +16,6 @@
  */
 import '@polymer/paper-button/paper-button';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {htmlTemplate} from './gr-button_html';
@@ -35,8 +33,8 @@
 }
 
 @customElement('gr-button')
-export class GrButton extends LegacyElementMixin(
-  KeyboardShortcutMixin(TooltipMixin(GestureEventListeners(PolymerElement)))
+export class GrButton extends KeyboardShortcutMixin(
+  TooltipMixin(PolymerElement)
 ) {
   static get template() {
     return htmlTemplate;
@@ -61,7 +59,7 @@
   tooltip = '';
 
   // Note: don't assign a value to this, since constructor is called
-  // after created, the initial value maybe overriden by this
+  // after created, the initial value maybe overridden by this
   @property({type: String})
   _initialTabindex?: string;
 
@@ -83,9 +81,8 @@
 
   private readonly reporting: ReportingService = appContext.reportingService;
 
-  /** @override */
-  created() {
-    super.created();
+  constructor() {
+    super();
     this._initialTabindex = this.getAttribute('tabindex') || '0';
     // TODO(TS): try avoid using unknown
     this.addEventListener('click', e =>
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
index b272951..4d94a98 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts
@@ -17,7 +17,7 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
-  <style include="shared-styles">
+  <style>
     /* general styles for all buttons */
     :host {
       --background-color: var(
@@ -53,7 +53,10 @@
       font: inherit;
       text-transform: uppercase;
       outline-width: 0;
-      border-radius: var(--border-radius);
+      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;
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
similarity index 69%
rename from polygerrit-ui/app/elements/shared/gr-button/gr-button_test.js
rename to polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
index 242cb28..f0f122a 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
@@ -15,18 +15,21 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-button.js';
-import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
-import {appContext} from '../../../services/app-context.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import '../../../test/common-test-setup-karma';
+import {addListener} from '@polymer/polymer/lib/utils/gestures';
+import {appContext} from '../../../services/app-context';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {GrButton} from './gr-button';
+import {queryAndAssert} from '../../../test/test-utils';
+import {PaperButtonElement} from '@polymer/paper-button';
 
 const basicFixture = fixtureFromElement('gr-button');
 
 const nestedFixture = fixtureFromTemplate(html`
-<div id="test">
-  <gr-button class="testBtn"></gr-button>
-</div>
+  <div id="test">
+    <gr-button class="testBtn"></gr-button>
+  </div>
 `);
 
 const tabindexFixture = fixtureFromTemplate(html`
@@ -34,11 +37,11 @@
 `);
 
 suite('gr-button tests', () => {
-  let element;
+  let element: GrButton;
 
-  const addSpyOn = function(eventName) {
+  const addSpyOn = function (eventName: string) {
     const spy = sinon.spy();
-    if (eventName == 'tap') {
+    if (eventName === 'tap') {
       addListener(element, eventName, spy);
     } else {
       element.addEventListener(eventName, spy);
@@ -51,7 +54,10 @@
   });
 
   test('disabled is set by disabled', () => {
-    const paperBtn = element.shadowRoot.querySelector('paper-button');
+    const paperBtn = queryAndAssert<PaperButtonElement>(
+      element,
+      'paper-button'
+    );
     assert.isFalse(paperBtn.disabled);
     element.disabled = true;
     assert.isTrue(paperBtn.disabled);
@@ -60,17 +66,21 @@
   });
 
   test('loading set from listener', () => {
-    let resolve;
+    let resolve: Function;
     element.addEventListener('click', e => {
-      e.target.loading = true;
-      resolve = () => e.target.loading = false;
+      const target = e.target as HTMLElement;
+      target.setAttribute('loading', 'true');
+      resolve = () => target.removeAttribute('loading');
     });
-    const paperBtn = element.shadowRoot.querySelector('paper-button');
+    const paperBtn = queryAndAssert<PaperButtonElement>(
+      element,
+      'paper-button'
+    );
     assert.isFalse(paperBtn.disabled);
     MockInteractions.tap(element);
     assert.isTrue(paperBtn.disabled);
     assert.isTrue(element.hasAttribute('loading'));
-    resolve();
+    resolve!();
     flush();
     assert.isFalse(paperBtn.disabled);
     assert.isFalse(element.hasAttribute('loading'));
@@ -92,13 +102,13 @@
   });
 
   test('tabindex should be preserved', () => {
-    element = tabindexFixture.instantiate();
-    element.disabled = false;
-    assert.equal(element.getAttribute('tabindex'), '3');
-    element.disabled = true;
-    assert.equal(element.getAttribute('tabindex'), '-1');
-    element.disabled = false;
-    assert.equal(element.getAttribute('tabindex'), '3');
+    const tabIndexElement = tabindexFixture.instantiate() as GrButton;
+    tabIndexElement.disabled = false;
+    assert.equal(tabIndexElement.getAttribute('tabindex'), '3');
+    tabIndexElement.disabled = true;
+    assert.equal(tabIndexElement.getAttribute('tabindex'), '-1');
+    tabIndexElement.disabled = false;
+    assert.equal(tabIndexElement.getAttribute('tabindex'), '3');
   });
 
   // 'tap' event is tested so we don't loose backward compatibility with older
@@ -123,14 +133,14 @@
 
   // Keycodes: 32 for Space, 13 for Enter.
   for (const key of [32, 13]) {
-    test('dispatches click event on keycode ' + key, () => {
+    test(`dispatches click event on keycode ${key}`, () => {
       const tapSpy = sinon.spy();
       element.addEventListener('click', tapSpy);
       MockInteractions.pressAndReleaseKeyOn(element, key);
       assert.isTrue(tapSpy.calledOnce);
     });
 
-    test('dispatches no click event with modifier on keycode ' + key, () => {
+    test(`dispatches no click event with modifier on keycode ${key}`, () => {
       const tapSpy = sinon.spy();
       element.addEventListener('click', tapSpy);
       MockInteractions.pressAndReleaseKeyOn(element, key, 'shift');
@@ -156,7 +166,7 @@
 
     // Keycodes: 32 for Space, 13 for Enter.
     for (const key of [32, 13]) {
-      test('stops click event on keycode ' + key, () => {
+      test(`stops click event on keycode ${key}`, () => {
         const tapSpy = sinon.spy();
         element.addEventListener('click', tapSpy);
         MockInteractions.pressAndReleaseKeyOn(element, key);
@@ -166,10 +176,9 @@
   });
 
   suite('reporting', () => {
-    let reportStub;
+    let reportStub: sinon.SinonStub;
     setup(() => {
-      reportStub = sinon.stub(appContext.reportingService,
-          'reportInteraction');
+      reportStub = sinon.stub(appContext.reportingService, 'reportInteraction');
       reportStub.reset();
     });
 
@@ -178,20 +187,20 @@
       assert.isTrue(reportStub.calledOnce);
       assert.equal(reportStub.lastCall.args[0], 'button-click');
       assert.deepEqual(reportStub.lastCall.args[1], {
-        path: `html>body>test-fixture#${basicFixture.fixtureId}>gr-button`,
+        path: `html>body>test-fixture#${element.parentElement!.id}>gr-button`,
       });
     });
 
     test('report event after click on nested', () => {
-      element = nestedFixture.instantiate();
-      MockInteractions.click(element.querySelector('gr-button'));
+      const nestedElement = nestedFixture.instantiate() as HTMLDivElement;
+      MockInteractions.click(queryAndAssert(nestedElement, 'gr-button'));
       assert.isTrue(reportStub.calledOnce);
       assert.equal(reportStub.lastCall.args[0], 'button-click');
       assert.deepEqual(reportStub.lastCall.args[1], {
-        path: `html>body>test-fixture#${nestedFixture.fixtureId}` +
-            `>div#test>gr-button.testBtn`,
+        path:
+          `html>body>test-fixture#${nestedElement.parentElement!.id}` +
+          '>div#test>gr-button.testBtn',
       });
     });
   });
 });
-
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 1ecaf7f..2b66af8 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
@@ -16,13 +16,12 @@
  */
 import '../gr-icons/gr-icons';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-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';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -36,9 +35,7 @@
 }
 
 @customElement('gr-change-star')
-export class GrChangeStar extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrChangeStar extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -78,6 +75,7 @@
       change: this.change,
       starred: newVal,
     };
+    if (newVal) fireAlert(this, 'Starring change...');
     this.dispatchEvent(
       new CustomEvent('toggle-star', {
         bubbles: true,
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.js
deleted file mode 100644
index f05ed21..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.js
+++ /dev/null
@@ -1,72 +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-change-star.js';
-
-const basicFixture = fixtureFromElement('gr-change-star');
-
-suite('gr-change-star tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    element.change = {
-      _number: 2,
-      starred: true,
-    };
-  });
-
-  test('star visibility states', () => {
-    element.set('change.starred', true);
-    let icon = element.shadowRoot
-        .querySelector('iron-icon');
-    assert.isTrue(icon.classList.contains('active'));
-    assert.equal(icon.icon, 'gr-icons:star');
-
-    element.set('change.starred', false);
-    icon = element.shadowRoot
-        .querySelector('iron-icon');
-    assert.isFalse(icon.classList.contains('active'));
-    assert.equal(icon.icon, 'gr-icons:star-border');
-  });
-
-  test('starring', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    element.addEventListener('toggle-star', resolve);
-    element.set('change.starred', false);
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('button'));
-
-    await promise;
-    assert.equal(element.change.starred, true);
-  });
-
-  test('unstarring', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    element.addEventListener('toggle-star', resolve);
-    element.set('change.starred', true);
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('button'));
-
-    await promise;
-    assert.equal(element.change.starred, false);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts
new file mode 100644
index 0000000..8f411ae
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts
@@ -0,0 +1,71 @@
+/**
+ * @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 {IronIconElement} from '@polymer/iron-icon';
+import '../../../test/common-test-setup-karma';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrChangeStar} from './gr-change-star';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {createChange} from '../../../test/test-data-generators';
+
+const basicFixture = fixtureFromElement('gr-change-star');
+
+suite('gr-change-star tests', () => {
+  let element: GrChangeStar;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.change = {
+      ...createChange(),
+      starred: true,
+    };
+  });
+
+  test('star visibility states', async () => {
+    element.set('change.starred', true);
+    await flush();
+    let icon = queryAndAssert<IronIconElement>(element, 'iron-icon');
+    assert.isTrue(icon.classList.contains('active'));
+    assert.equal(icon.icon, 'gr-icons:star');
+
+    element.set('change.starred', false);
+    await flush();
+    icon = queryAndAssert<IronIconElement>(element, 'iron-icon');
+    assert.isFalse(icon.classList.contains('active'));
+    assert.equal(icon.icon, 'gr-icons:star-border');
+  });
+
+  test('starring', async () => {
+    element.set('change.starred', false);
+    await flush();
+    assert.equal(element.change!.starred, false);
+
+    MockInteractions.tap(queryAndAssert(element, 'button'));
+    await flush();
+    assert.equal(element.change!.starred, true);
+  });
+
+  test('unstarring', async () => {
+    element.set('change.starred', true);
+    await flush();
+    assert.equal(element.change!.starred, true);
+
+    MockInteractions.tap(queryAndAssert(element, 'button'));
+    await flush();
+    assert.equal(element.change!.starred, false);
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index 44ec4f5..1fd019f 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -14,11 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-status_html';
 import {customElement, property} from '@polymer/decorators';
@@ -36,20 +33,17 @@
   "It will not appear on dashboards unless you are CC'ed or assigned, " +
   'and email notifications will be silenced until the review is started.';
 
-const MERGE_CONFLICT_TOOLTIP =
+export const MERGE_CONFLICT_TOOLTIP =
   'This change has merge conflicts. ' +
-  'Download the patch and run "git rebase master". ' +
+  'Download the patch and run "git rebase". ' +
   'Upload a new patchset after resolving all merge conflicts.';
 
 const PRIVATE_TOOLTIP =
   'This change is only visible to its owner and ' +
   'current reviewers (or anyone with "View Private Changes" permission).';
 
-/** @extends PolymerElement */
 @customElement('gr-change-status')
-class GrChangeStatus extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrChangeStatus extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
index 770a21c..16fc664 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-change-status.js';
+import {MERGE_CONFLICT_TOOLTIP} from './gr-change-status.js';
 
 const basicFixture = fixtureFromElement('gr-change-status');
 
@@ -24,10 +25,6 @@
     'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
     'and email notifications will be silenced until the review is started.';
 
-const MERGE_CONFLICT_TOOLTIP = 'This change has merge conflicts. ' +
-  'Download the patch and run "git rebase master". ' +
-  'Upload a new patchset after resolving all merge conflicts.';
-
 const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
     'current reviewers (or anyone with "View Private Changes" permission).';
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 878c01c..f97146e 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -15,16 +15,14 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import '../gr-rest-api-interface/gr-rest-api-interface';
-import '../gr-storage/gr-storage';
 import '../gr-comment/gr-comment';
+import '../../diff/gr-diff/gr-diff';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 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,
   isDraft,
   isRobot,
   sortComments,
@@ -34,10 +32,14 @@
 } from '../../../utils/comment-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
-import {CommentSide, Side, SpecialFilePath} from '../../../constants/constants';
+import {
+  CommentSide,
+  createDefaultDiffPrefs,
+  Side,
+  SpecialFilePath,
+} from '../../../constants/constants';
 import {computeDisplayPath} from '../../../utils/path-list-util';
-import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {computed, customElement, observe, property} from '@polymer/decorators';
 import {
   CommentRange,
   ConfigInfo,
@@ -48,23 +50,28 @@
 } from '../../../types/common';
 import {GrComment} from '../gr-comment/gr-comment';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
 import {CustomKeyboardEvent} from '../../../types/events';
+import {LineNumber, FILE} from '../../diff/gr-diff/gr-diff-line';
+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 {GrSyntaxLayer} from '../../diff/gr-syntax-layer/gr-syntax-layer';
+import {StorageLocation} from '../../../services/storage/gr-storage';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
 
 export interface GrCommentThread {
   $: {
-    restAPI: RestApiService & Element;
-    storage: GrStorage;
+    replyBtn: GrButton;
+    quoteBtn: GrButton;
   };
 }
 
 @customElement('gr-comment-thread')
-export class GrCommentThread extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrCommentThread extends KeyboardShortcutMixin(PolymerElement) {
   // KeyboardShortcutMixin Not used in this element rather other elements tests
 
   static get template() {
@@ -88,9 +95,9 @@
    * diff widget like gr-diff to show the thread in the right location:
    *
    * line-num:
-   *     1-based line number or undefined if it refers to the entire file.
+   *     1-based line number or 'FILE' if it refers to the entire file.
    *
-   * comment-side:
+   * diff-side:
    *     "left" or "right". These indicate which of the two diffed versions
    *     the comment relates to. In the case of unified diff, the left
    *     version is the one whose line number column is further to the left.
@@ -116,13 +123,13 @@
   keyEventTarget: HTMLElement = document.body;
 
   @property({type: String, reflectToAttribute: true})
-  commentSide?: Side;
+  diffSide?: Side;
 
   @property({type: String})
   patchNum?: PatchSetNum;
 
   @property({type: String})
-  path?: string;
+  path: string | undefined;
 
   @property({type: String, observer: '_projectNameChanged'})
   projectName?: RepoName;
@@ -146,8 +153,8 @@
   @property({type: Boolean})
   showFilePath = false;
 
-  @property({type: Number, reflectToAttribute: true})
-  lineNum?: number;
+  @property({type: Object, reflectToAttribute: true})
+  lineNum?: LineNumber;
 
   @property({type: Boolean, notify: true, reflectToAttribute: true})
   unresolved?: boolean;
@@ -164,6 +171,17 @@
   @property({type: Object})
   _projectConfig?: ConfigInfo;
 
+  @property({type: Object})
+  _prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+
+  @property({type: Object})
+  _renderPrefs: RenderPreferences = {
+    hide_left_side: true,
+    disable_context_control_buttons: true,
+    show_file_comment_button: false,
+    hide_line_length_indicator: true,
+  };
+
   @property({type: Boolean, reflectToAttribute: true})
   isRobotComment = false;
 
@@ -171,8 +189,14 @@
   showFileName = true;
 
   @property({type: Boolean})
+  showPortedComment = false;
+
+  @property({type: Boolean})
   showPatchset = true;
 
+  @property({type: Boolean})
+  showCommentContext = false;
+
   get keyBindings() {
     return {
       'e shift+e': '_handleEKey',
@@ -183,24 +207,59 @@
 
   flagsService = appContext.flagsService;
 
-  /** @override */
-  created() {
-    super.created();
+  readonly storage = appContext.storageService;
+
+  private readonly syntaxLayer = new GrSyntaxLayer();
+
+  readonly restApiService = appContext.restApiService;
+
+  constructor() {
+    super();
     this.addEventListener('comment-update', e =>
       this._handleCommentUpdate(e as CustomEvent)
     );
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._getLoggedIn().then(loggedIn => {
       this._showActions = loggedIn;
     });
+    this.restApiService.getDiffPreferences().then(prefs => {
+      if (!prefs) return;
+      this._prefs = {
+        ...prefs,
+        // set line_wrapping to true so that the context can take all the
+        // remaining space after comment card has rendered
+        line_wrapping: true,
+      };
+      this.syntaxLayer.setEnabled(!!prefs.syntax_highlighting);
+    });
     this._setInitialExpandedState();
   }
 
-  addOrEditDraft(lineNum?: number, rangeParam?: CommentRange) {
+  @computed('comments', 'path')
+  get _diff() {
+    if (this.comments === undefined || this.path === undefined) return;
+    if (!this.comments[0]?.context_lines?.length) return;
+    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);
+    return diff;
+  }
+
+  _shouldShowCommentContext(diff?: DiffInfo) {
+    return this.showCommentContext && !!diff;
+  }
+
+  addOrEditDraft(lineNum?: LineNumber, rangeParam?: CommentRange) {
     const lastComment = this.comments[this.comments.length - 1] || {};
     if (isDraft(lastComment)) {
       const commentEl = this._commentElWithDraftID(
@@ -223,7 +282,7 @@
     }
   }
 
-  addDraft(lineNum?: number, range?: CommentRange, unresolved?: boolean) {
+  addDraft(lineNum?: LineNumber, range?: CommentRange, unresolved?: boolean) {
     const draft = this._newDraft(lineNum, range);
     draft.__editing = true;
     draft.unresolved = unresolved === false ? unresolved : true;
@@ -239,20 +298,55 @@
     );
   }
 
-  _getDiffUrlForPath(path: string) {
-    if (!this.changeNum) throw new Error('changeNum is missing');
-    if (!this.projectName) throw new Error('projectName is missing');
+  _getDiffUrlForPath(
+    projectName?: RepoName,
+    changeNum?: NumericChangeId,
+    path?: string,
+    patchNum?: PatchSetNum
+  ) {
+    if (!changeNum || !projectName || !path) return undefined;
     if (isDraft(this.comments[0])) {
       return GerritNav.getUrlForDiffById(
-        this.changeNum,
-        this.projectName,
+        changeNum,
+        projectName,
         path,
-        this.patchNum
+        patchNum
       );
     }
     const id = this.comments[0].id;
     if (!id) throw new Error('A published comment is missing the id.');
-    return GerritNav.getUrlForComment(this.changeNum, this.projectName, id);
+    return GerritNav.getUrlForComment(changeNum, projectName, id);
+  }
+
+  getHighlightRange() {
+    const comment = this.comments?.[0];
+    if (!comment) return undefined;
+    if (comment.range) return comment.range;
+    if (comment.line) {
+      return {
+        start_line: comment.line,
+        start_character: 0,
+        end_line: comment.line,
+        end_character: 0,
+      };
+    }
+    return undefined;
+  }
+
+  _getLayers(diff?: DiffInfo) {
+    if (!diff) return [];
+    return [this.syntaxLayer];
+  }
+
+  _getUrlForViewDiff(comments: UIComment[]) {
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.projectName, 'projectName');
+    check(comments.length > 0, 'comment not found');
+    return GerritNav.getUrlForComment(
+      this.changeNum,
+      this.projectName,
+      comments[0].id!
+    );
   }
 
   _getDiffUrlForComment(
@@ -266,13 +360,14 @@
       (this.comments.length && this.comments[0].side === 'PARENT') ||
       isDraft(this.comments[0])
     ) {
+      if (this.lineNum === 'LOST') throw new Error('invalid lineNum lost');
       return GerritNav.getUrlForDiffById(
         changeNum,
         projectName,
         path,
         patchNum,
         undefined,
-        this.lineNum
+        this.lineNum === FILE ? undefined : this.lineNum
       );
     }
     const id = this.comments[0].id;
@@ -284,6 +379,11 @@
     return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
   }
 
+  _computeShowPortedComment(comment: UIComment) {
+    if (this._orderedComments.length === 0) return false;
+    return this.showPortedComment && comment.id === this._orderedComments[0].id;
+  }
+
   _computeDisplayPath(path: string) {
     const displayPath = computeDisplayPath(path);
     if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
@@ -293,20 +393,24 @@
   }
 
   _computeDisplayLine() {
-    if (this.lineNum) return `#${this.lineNum}`;
-    // If range is set, then lineNum equals the end line of the range.
-    if (!this.lineNum && !this.range) {
+    if (this.lineNum === FILE) {
       if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
         return '';
       }
-      return 'FILE';
+      return FILE;
     }
+    if (this.lineNum) return `#${this.lineNum}`;
+    // If range is set, then lineNum equals the end line of the range.
     if (this.range) return `#${this.range.end_line}`;
     return '';
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
+  }
+
+  _getUnresolvedLabel(unresolved?: boolean) {
+    return unresolved ? 'Unresolved' : 'Resolved';
   }
 
   @observe('comments.*')
@@ -397,15 +501,6 @@
     if (!id) throw new Error('Cannot reply to comment without id.');
     const reply = this._newReply(id, content, unresolved);
 
-    // If there is currently a comment in an editing state, add an attribute
-    // so that the gr-comment knows not to populate the draft text.
-    for (let i = 0; i < this.comments.length; i++) {
-      if (this.comments[i].__editing) {
-        reply.__otherEditing = true;
-        break;
-      }
-    }
-
     if (isEditing) {
       reply.__editing = true;
     }
@@ -414,7 +509,7 @@
 
     if (!isEditing) {
       // Allow the reply to render in the dom-repeat.
-      this.async(() => {
+      setTimeout(() => {
         const commentEl = this._commentElWithDraftID(reply.__draftID);
         if (commentEl) commentEl.save();
       }, 1);
@@ -490,13 +585,13 @@
     return d;
   }
 
-  _newDraft(lineNum?: number, range?: CommentRange) {
+  _newDraft(lineNum?: LineNumber, range?: CommentRange) {
     const d: UIDraft = {
       __draft: true,
       __draftID: Math.random().toString(36),
       __date: new Date(),
     };
-
+    if (lineNum === 'LOST') throw new Error('invalid lineNum lost');
     // For replies, always use same meta info as root.
     if (this.comments && this.comments.length >= 1) {
       const rootComment = this.comments[0];
@@ -504,8 +599,6 @@
       if (rootComment.patch_set !== undefined)
         d.patch_set = rootComment.patch_set;
       if (rootComment.side !== undefined) d.side = rootComment.side;
-      if (rootComment.__commentSide !== undefined)
-        d.__commentSide = rootComment.__commentSide;
       if (rootComment.line !== undefined) d.line = rootComment.line;
       if (rootComment.range !== undefined) d.range = rootComment.range;
       if (rootComment.parent !== undefined) d.parent = rootComment.parent;
@@ -514,9 +607,8 @@
       d.path = this.path;
       d.patch_set = this.patchNum;
       d.side = this._getSide(this.isOnParent);
-      d.__commentSide = this.commentSide;
 
-      if (lineNum) {
+      if (lineNum && lineNum !== FILE) {
         d.line = lineNum;
       }
       if (range) {
@@ -546,8 +638,8 @@
   }
 
   _handleCommentDiscard(e: Event) {
-    if (!this.changeNum) throw new Error('changeNum is missing');
-    if (!this.patchNum) throw new Error('patchNum is missing');
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.patchNum, 'patchNum');
     const diffCommentEl = (dom(e) as EventApi).rootTarget as GrComment;
     const comment = diffCommentEl.comment;
     const idx = this._indexOf(comment, this.comments);
@@ -565,14 +657,14 @@
     // Check to see if there are any other open comments getting edited and
     // set the local storage value to its message value.
     for (const changeComment of this.comments) {
-      if (changeComment.__editing) {
+      if (isDraft(changeComment) && changeComment.__editing) {
         const commentLocation: StorageLocation = {
           changeNum: this.changeNum,
           patchNum: this.patchNum,
           path: changeComment.path,
           line: changeComment.line,
         };
-        this.$.storage.setDraftComment(
+        this.storage.setDraftComment(
           commentLocation,
           changeComment.message ?? ''
         );
@@ -635,7 +727,7 @@
     if (!name) {
       return;
     }
-    this.$.restAPI.getProjectConfig(name).then(config => {
+    this.restApiService.getProjectConfig(name).then(config => {
       this._projectConfig = config;
     });
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
index 4c15383..a1644e7 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
@@ -23,6 +23,12 @@
       font-size: var(--font-size-normal);
       font-weight: var(--font-weight-normal);
       line-height: var(--line-height-normal);
+      /* Explicitly set the background color of the diff. We
+       * cannot use the diff content type ab because of the skip chunk preceding
+       * it, diff processor assumes the chunk of type skip/ab can be collapsed
+       * and hides our diff behind context control buttons.
+       *  */
+      --dark-add-highlight-color: var(--background-color-primary);
     }
     gr-button {
       margin-left: var(--spacing-m);
@@ -34,24 +40,30 @@
       margin-left: auto;
       padding: var(--spacing-s) var(--spacing-m);
     }
-    #container {
+    .comment-box {
+      width: 80ch;
+      max-width: 100%;
       background-color: var(--comment-background-color);
       color: var(--comment-text-color);
-      display: var(--gr-comment-thread-display, block);
-      margin: 0 var(--spacing-s) var(--spacing-s);
-      white-space: normal;
       box-shadow: var(--elevation-level-2);
       border-radius: var(--border-radius);
+      flex-shrink: 0;
+    }
+    #container {
+      display: var(--gr-comment-thread-display, flex);
+      align-items: flex-start;
+      margin: 0 var(--spacing-s) var(--spacing-s);
+      white-space: normal;
       /** This is required for firefox to continue the inheritance */
       -webkit-user-select: inherit;
       -moz-user-select: inherit;
       -ms-user-select: inherit;
       user-select: inherit;
     }
-    #container.unresolved {
+    .comment-box.unresolved {
       background-color: var(--unresolved-comment-background-color);
     }
-    #container.robotComment {
+    .comment-box.robotComment {
       background-color: var(--robot-comment-background-color);
     }
     #commentInfoContainer {
@@ -75,7 +87,27 @@
     .fileName {
       padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
     }
+    @media only screen and (max-width: 1200px) {
+      .diff-container {
+        display: none;
+      }
+    }
+    .diff-container {
+      margin-left: var(--spacing-l);
+      border: 1px solid var(--border-color);
+      flex-grow: 1;
+      flex-shrink: 1;
+      max-width: 1200px;
+    }
+    .view-diff-button {
+      margin: var(--spacing-s) var(--spacing-m);
+    }
+    .view-diff-container {
+      border-top: 1px solid var(--border-color);
+      background-color: var(--background-color-primary);
+    }
   </style>
+
   <template is="dom-if" if="[[showFilePath]]">
     <template is="dom-if" if="[[showFileName]]">
       <div class="fileName">
@@ -83,7 +115,9 @@
           <span> [[_computeDisplayPath(path)]] </span>
         </template>
         <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
-          <a href$="[[_getDiffUrlForPath(path)]]">
+          <a
+            href$="[[_getDiffUrlForPath(projectName, changeNum, path, patchNum)]]"
+          >
             [[_computeDisplayPath(path)]]
           </a>
         </template>
@@ -98,72 +132,92 @@
       </template>
     </div>
   </template>
-  <div
-    id="container"
-    class$="[[_computeHostClass(unresolved, isRobotComment)]]"
-  >
-    <template
-      id="commentList"
-      is="dom-repeat"
-      items="[[_orderedComments]]"
-      as="comment"
-    >
-      <gr-comment
-        comment="{{comment}}"
-        comments="{{comments}}"
-        robot-button-disabled="[[_shouldDisableAction(_showActions, _lastComment)]]"
-        change-num="[[changeNum]]"
-        patch-num="[[patchNum]]"
-        draft="[[_isDraft(comment)]]"
-        show-actions="[[_showActions]]"
-        show-patchset="[[showPatchset]]"
-        comment-side="[[comment.__commentSide]]"
-        side="[[comment.side]]"
-        project-config="[[_projectConfig]]"
-        on-create-fix-comment="_handleCommentFix"
-        on-comment-discard="_handleCommentDiscard"
-        on-comment-save="_handleCommentSavedOrDiscarded"
-      ></gr-comment>
-    </template>
-    <div
-      id="commentInfoContainer"
-      hidden$="[[_hideActions(_showActions, _lastComment)]]"
-    >
-      <span id="unresolvedLabel" hidden$="[[!unresolved]]">Unresolved</span>
-      <div id="actions">
-        <gr-button
-          id="replyBtn"
-          link=""
-          class="action reply"
-          on-click="_handleCommentReply"
-          >Reply</gr-button
-        >
-        <gr-button
-          id="quoteBtn"
-          link=""
-          class="action quote"
-          on-click="_handleCommentQuote"
-          >Quote</gr-button
-        >
-        <template is="dom-if" if="[[unresolved]]">
+  <div id="container">
+    <div class$="[[_computeHostClass(unresolved, isRobotComment)]] comment-box">
+      <template
+        id="commentList"
+        is="dom-repeat"
+        items="[[_orderedComments]]"
+        as="comment"
+      >
+        <gr-comment
+          comment="{{comment}}"
+          comments="{{comments}}"
+          robot-button-disabled="[[_shouldDisableAction(_showActions, _lastComment)]]"
+          change-num="[[changeNum]]"
+          project-name="[[projectName]]"
+          patch-num="[[patchNum]]"
+          draft="[[_isDraft(comment)]]"
+          show-actions="[[_showActions]]"
+          show-patchset="[[showPatchset]]"
+          show-ported-comment="[[_computeShowPortedComment(comment)]]"
+          side="[[comment.side]]"
+          project-config="[[_projectConfig]]"
+          on-create-fix-comment="_handleCommentFix"
+          on-comment-discard="_handleCommentDiscard"
+          on-comment-save="_handleCommentSavedOrDiscarded"
+        ></gr-comment>
+      </template>
+      <div
+        id="commentInfoContainer"
+        hidden$="[[_hideActions(_showActions, _lastComment)]]"
+      >
+        <span id="unresolvedLabel">[[_getUnresolvedLabel(unresolved)]]</span>
+        <div id="actions">
           <gr-button
-            id="ackBtn"
+            id="replyBtn"
             link=""
-            class="action ack"
-            on-click="_handleCommentAck"
-            >Ack</gr-button
+            class="action reply"
+            on-click="_handleCommentReply"
+            >Reply</gr-button
           >
           <gr-button
-            id="doneBtn"
+            id="quoteBtn"
             link=""
-            class="action done"
-            on-click="_handleCommentDone"
-            >Done</gr-button
+            class="action quote"
+            on-click="_handleCommentQuote"
+            >Quote</gr-button
           >
-        </template>
+          <template is="dom-if" if="[[unresolved]]">
+            <gr-button
+              id="ackBtn"
+              link=""
+              class="action ack"
+              on-click="_handleCommentAck"
+              >Ack</gr-button
+            >
+            <gr-button
+              id="doneBtn"
+              link=""
+              class="action done"
+              on-click="_handleCommentDone"
+              >Done</gr-button
+            >
+          </template>
+        </div>
       </div>
     </div>
+    <template is="dom-if" if="[[_shouldShowCommentContext(_diff)]]">
+      <div class="diff-container">
+        <gr-diff
+          id="diff"
+          change-num="[[changeNum]]"
+          diff="[[_diff]]"
+          layers="[[_getLayers(_diff)]]"
+          path="[[path]]"
+          prefs="[[_prefs]]"
+          render-prefs="[[_renderPrefs]]"
+          highlight-range="[[getHighlightRange(comments)]]"
+        >
+        </gr-diff>
+        <div class="view-diff-container">
+          <a href="[[_getUrlForViewDiff(comments)]]">
+            <gr-button link class="view-diff-button" on-click="_handleViewDiff">
+              View Diff
+            </gr-button>
+          </a>
+        </div>
+      </div>
+    </template>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
deleted file mode 100644
index 1833b73..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
+++ /dev/null
@@ -1,879 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-comment-thread.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-import {sortComments} from '../../../utils/comment-util.js';
-
-const basicFixture = fixtureFromElement('gr-comment-thread');
-
-const withCommentFixture = fixtureFromElement('gr-comment-thread');
-
-suite('gr-comment-thread tests', () => {
-  suite('basic test', () => {
-    let element;
-
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-
-      element = basicFixture.instantiate();
-      element.patchNum = '3';
-      element.changeNum = '1';
-      flush();
-    });
-
-    test('comments are sorted correctly', () => {
-      const comments = [
-        {
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          __date: new Date('2015-12-25'),
-        }, {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sally_to_dr_finklestein',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }, {
-          id: 'dr_finklesteins_response',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000',
-        }, {
-          id: 'sallys_mission',
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000',
-        },
-      ];
-      const results = sortComments(comments);
-      assert.deepEqual(results, [
-        {
-          id: 'sally_to_dr_finklestein',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'dr_finklesteins_response',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }, {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sallys_mission',
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          __date: new Date('2015-12-25'),
-        },
-      ]);
-    });
-
-    test('addOrEditDraft w/ edit draft', () => {
-      element.comments = [{
-        id: 'jacks_reply',
-        message: 'i like you, too',
-        in_reply_to: 'sallys_confession',
-        updated: '2015-12-25 15:00:20.396000000',
-        __draft: true,
-      }];
-      const commentElStub = sinon.stub(element, '_commentElWithDraftID')
-          .callsFake(() => { return {}; });
-      const addDraftStub = sinon.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isTrue(commentElStub.called);
-      assert.isFalse(addDraftStub.called);
-    });
-
-    test('addOrEditDraft w/o edit draft', () => {
-      element.comments = [];
-      const commentElStub = sinon.stub(element, '_commentElWithDraftID')
-          .callsFake(() => { return {}; });
-      const addDraftStub = sinon.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isFalse(commentElStub.called);
-      assert.isTrue(addDraftStub.called);
-    });
-
-    test('_shouldDisableAction', () => {
-      let showActions = true;
-      const lastComment = {};
-      assert.equal(
-          element._shouldDisableAction(showActions, lastComment), false);
-      showActions = false;
-      assert.equal(
-          element._shouldDisableAction(showActions, lastComment), true);
-      showActions = true;
-      lastComment.__draft = true;
-      assert.equal(
-          element._shouldDisableAction(showActions, lastComment), true);
-      const robotComment = {};
-      robotComment.robot_id = true;
-      assert.equal(
-          element._shouldDisableAction(showActions, robotComment), false);
-    });
-
-    test('_hideActions', () => {
-      let showActions = true;
-      const lastComment = {};
-      assert.equal(element._hideActions(showActions, lastComment), false);
-      showActions = false;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      showActions = true;
-      lastComment.__draft = true;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      const robotComment = {};
-      robotComment.robot_id = true;
-      assert.equal(element._hideActions(showActions, robotComment), true);
-    });
-
-    test('setting project name loads the project config', done => {
-      const projectName = 'foo/bar/baz';
-      const getProjectStub = sinon.stub(element.$.restAPI, 'getProjectConfig')
-          .returns(Promise.resolve({}));
-      element.projectName = projectName;
-      flush(() => {
-        assert.isTrue(getProjectStub.calledWithExactly(projectName));
-        done();
-      });
-    });
-
-    test('optionally show file path', () => {
-      // Path info doesn't exist when showFilePath is false. Because it's in a
-      // dom-if it is not yet in the dom.
-      assert.isNotOk(element.shadowRoot
-          .querySelector('.pathInfo'));
-
-      const commentStub = sinon.stub(GerritNav, 'getUrlForComment');
-      element.changeNum = 123;
-      element.projectName = 'test project';
-      element.path = 'path/to/file';
-      element.latestPatchNum = 10;
-      element.patchNum = 3;
-      element.lineNum = 5;
-      element.comments = [{id: 'comment_id'}];
-      element.showFilePath = true;
-      flush();
-      assert.isOk(element.shadowRoot
-          .querySelector('.pathInfo'));
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.pathInfo')).display,
-      'none');
-      assert.isTrue(commentStub.calledWithExactly(
-          element.changeNum, element.projectName, 'comment_id'));
-    });
-
-    test('_computeDisplayPath', () => {
-      let path = 'path/to/file';
-      assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
-      element.lineNum = 5;
-      assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
-      element.patchNum = '3';
-      path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-      assert.equal(element._computeDisplayPath(path), 'Patchset');
-    });
-
-    test('_computeDisplayLine', () => {
-      element.lineNum = 5;
-      assert.equal(element._computeDisplayLine(), '#5');
-
-      element.path = SpecialFilePath.COMMIT_MESSAGE;
-      element.lineNum = 5;
-      assert.equal(element._computeDisplayLine(), '#5');
-
-      element.lineNum = undefined;
-      element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-      assert.equal(element._computeDisplayLine(), '');
-    });
-  });
-});
-
-suite('comment action tests with unresolved thread', () => {
-  let element;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      saveDiffDraft() {
-        return Promise.resolve({
-          ok: true,
-          text() {
-            return Promise.resolve(')]}\'\n' +
-                JSON.stringify({
-                  id: '7afa4931_de3d65bd',
-                  path: '/path/to/file.txt',
-                  line: 5,
-                  in_reply_to: 'baf0414d_60047215',
-                  updated: '2015-12-21 02:01:10.850000000',
-                  message: 'Done',
-                }));
-          },
-        });
-      },
-      deleteDiffDraft() { return Promise.resolve({ok: true}); },
-    });
-    element = withCommentFixture.instantiate();
-    element.patchNum = '1';
-    element.changeNum = '1';
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      line: 5,
-      message: 'is this a crossover episode!?',
-      updated: '2015-12-08 19:48:33.843000000',
-      path: '/path/to/file.txt',
-      unresolved: true,
-      patch_set: 3,
-      __commentSide: 'left',
-    }];
-    flush();
-  });
-
-  test('reply', () => {
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    const reportStub = sinon.stub(element.reporting,
-        'recordDraftInteraction');
-    assert.ok(commentEl);
-
-    const replyBtn = element.$.replyBtn;
-    MockInteractions.tap(replyBtn);
-    flush();
-
-    const drafts = element._orderedComments.filter(c => c.__draft == true);
-    assert.equal(drafts.length, 1);
-    assert.notOk(drafts[0].message, 'message should be empty');
-    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('quote reply', () => {
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    const reportStub = sinon.stub(element.reporting,
-        'recordDraftInteraction');
-    assert.ok(commentEl);
-
-    const quoteBtn = element.$.quoteBtn;
-    MockInteractions.tap(quoteBtn);
-    flush();
-
-    const drafts = element._orderedComments.filter(c => c.__draft == true);
-    assert.equal(drafts.length, 1);
-    assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
-    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('quote reply multiline', () => {
-    const reportStub = sinon.stub(element.reporting,
-        'recordDraftInteraction');
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      path: 'test',
-      line: 5,
-      message: 'is this a crossover episode!?\nIt might be!',
-      updated: '2015-12-08 19:48:33.843000000',
-    }];
-    flush();
-
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const quoteBtn = element.$.quoteBtn;
-    MockInteractions.tap(quoteBtn);
-    flush();
-
-    const drafts = element._orderedComments.filter(c => c.__draft == true);
-    assert.equal(drafts.length, 1);
-    assert.equal(drafts[0].message,
-        '> is this a crossover episode!?\n> It might be!\n\n');
-    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('ack', done => {
-    const reportStub = sinon.stub(element.reporting,
-        'recordDraftInteraction');
-    element.changeNum = '42';
-    element.patchNum = '1';
-
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const ackBtn = element.shadowRoot.querySelector('#ackBtn');
-    MockInteractions.tap(ackBtn);
-    flush(() => {
-      const drafts = element.comments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].message, 'Ack');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      assert.equal(drafts[0].unresolved, false);
-      assert.isTrue(reportStub.calledOnce);
-      done();
-    });
-  });
-
-  test('done', done => {
-    const reportStub = sinon.stub(element.reporting,
-        'recordDraftInteraction');
-    element.changeNum = '42';
-    element.patchNum = '1';
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const doneBtn = element.shadowRoot.querySelector('#doneBtn');
-    MockInteractions.tap(doneBtn);
-    flush(() => {
-      const drafts = element.comments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].message, 'Done');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      assert.isFalse(drafts[0].unresolved);
-      assert.isTrue(reportStub.calledOnce);
-      done();
-    });
-  });
-
-  test('save', done => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-    element.path = '/path/to/file.txt';
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
-    element.shadowRoot
-        .querySelector('gr-comment')._fireSave();
-
-    flush(() => {
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-          'baf0414d_60047215');
-      assert.equal(element.rootId, 'baf0414d_60047215');
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-          '/path/to/file.txt');
-      done();
-    });
-  });
-
-  test('please fix', done => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-    commentEl.addEventListener('create-fix-comment', () => {
-      const drafts = element._orderedComments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 1);
-      assert.equal(
-          drafts[0].message, '> is this a crossover episode!?\n\nPlease fix.');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      assert.isTrue(drafts[0].unresolved);
-      done();
-    });
-    commentEl.dispatchEvent(
-        new CustomEvent('create-fix-comment', {
-          detail: {comment: commentEl.comment},
-          composed: true, bubbles: false,
-        }));
-  });
-
-  test('discard', done => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-    element.path = '/path/to/file.txt';
-    element.push('comments', element._newReply(
-        element.comments[0].id,
-        element.comments[0].path,
-        'it’s pronouced jiff, not giff'));
-    flush();
-
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
-    const draftEl =
-        element.root.querySelectorAll('gr-comment')[1];
-    assert.ok(draftEl);
-    draftEl.addEventListener('comment-discard', () => {
-      const drafts = element.comments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 0);
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-          element.rootId);
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-          element.path);
-      done();
-    });
-    draftEl.dispatchEvent(
-        new CustomEvent('comment-discard', {
-          detail: {comment: draftEl.comment},
-          composed: true, bubbles: false,
-        }));
-  });
-
-  test('discard with a single comment still fires event with previous rootId',
-      done => {
-        element.changeNum = '42';
-        element.patchNum = '1';
-        element.path = '/path/to/file.txt';
-        element.comments = [];
-        element.addOrEditDraft('1');
-        flush();
-        const rootId = element.rootId;
-        assert.isOk(rootId);
-
-        const saveOrDiscardStub = sinon.stub();
-        element.addEventListener('thread-changed', saveOrDiscardStub);
-        const draftEl =
-        element.root.querySelectorAll('gr-comment')[0];
-        assert.ok(draftEl);
-        draftEl.addEventListener('comment-discard', () => {
-          assert.equal(element.comments.length, 0);
-          assert.isTrue(saveOrDiscardStub.called);
-          assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-              rootId);
-          assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-              element.path);
-          done();
-        });
-        draftEl.dispatchEvent(
-            new CustomEvent('comment-discard', {
-              detail: {comment: draftEl.comment},
-              composed: true, bubbles: false,
-            }));
-      });
-
-  test('first editing comment does not add __otherEditing attribute', () => {
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      line: 5,
-      path: 'test',
-      message: 'is this a crossover episode!?',
-      updated: '2015-12-08 19:48:33.843000000',
-      __draft: true,
-    }];
-
-    const replyBtn = element.$.replyBtn;
-    MockInteractions.tap(replyBtn);
-    flush();
-
-    const editing = element._orderedComments.filter(c => c.__editing == true);
-    assert.equal(editing.length, 1);
-    assert.equal(!!editing[0].__otherEditing, false);
-  });
-
-  test('When not editing other comments, local storage not set' +
-      ' after discard', done => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      path: 'test',
-      line: 5,
-      message: 'is this a crossover episode!?',
-      updated: '2015-12-08 19:48:31.843000000',
-    },
-    {
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      __draftID: '1',
-      in_reply_to: 'baf0414d_60047215',
-      path: 'test',
-      line: 5,
-      message: 'yes',
-      updated: '2015-12-08 19:48:32.843000000',
-      __draft: true,
-      __editing: true,
-    },
-    {
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      __draftID: '2',
-      in_reply_to: 'baf0414d_60047215',
-      path: 'test',
-      line: 5,
-      message: 'no',
-      updated: '2015-12-08 19:48:33.843000000',
-      __draft: true,
-    }];
-    const storageStub = sinon.stub(element.$.storage, 'setDraftComment');
-    flush();
-
-    const draftEl =
-    element.root.querySelectorAll('gr-comment')[1];
-    assert.ok(draftEl);
-    draftEl.addEventListener('comment-discard', () => {
-      assert.isFalse(storageStub.called);
-      storageStub.restore();
-      done();
-    });
-    draftEl.dispatchEvent(
-        new CustomEvent('comment-discard', {
-          detail: {comment: draftEl.comment},
-          composed: true, bubbles: false,
-        }));
-  });
-
-  test('comment-update', () => {
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    const updatedComment = {
-      id: element.comments[0].id,
-      foo: 'bar',
-    };
-    commentEl.dispatchEvent(
-        new CustomEvent('comment-update', {
-          detail: {comment: updatedComment},
-          composed: true, bubbles: true,
-        }));
-    assert.strictEqual(element.comments[0], updatedComment);
-  });
-
-  suite('jack and sally comment data test consolidation', () => {
-    setup(() => {
-      element.comments = [
-        {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          updated: '2015-12-25 15:00:20.396000000',
-          unresolved: false,
-        }, {
-          id: 'sallys_confession',
-          in_reply_to: 'nonexistent_comment',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sally_to_dr_finklestein',
-          in_reply_to: 'nonexistent_comment',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }];
-    });
-
-    test('orphan replies', () => {
-      assert.equal(4, element._orderedComments.length);
-    });
-
-    test('keyboard shortcuts', () => {
-      const expandCollapseStub =
-          sinon.stub(element, '_expandCollapseComments');
-      MockInteractions.pressAndReleaseKeyOn(element, 69, null, 'e');
-      assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
-
-      MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift', 'e');
-      assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
-    });
-
-    test('comment in_reply_to is either null or most recent comment', () => {
-      element._createReplyComment('dummy', true);
-      flush();
-      assert.equal(element._orderedComments.length, 5);
-      assert.equal(element._orderedComments[4].in_reply_to, 'jacks_reply');
-    });
-
-    test('resolvable comments', () => {
-      assert.isFalse(element.unresolved);
-      element._createReplyComment('dummy', true, true);
-      flush();
-      assert.isTrue(element.unresolved);
-    });
-
-    test('_setInitialExpandedState with unresolved', () => {
-      element.unresolved = true;
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isFalse(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState without unresolved', () => {
-      element.unresolved = false;
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isTrue(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState with robot_ids', () => {
-      for (let i = 0; i < element.comments.length; i++) {
-        element.comments[i].robot_id = 123;
-      }
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isFalse(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState with collapsed state', () => {
-      element.comments[0].collapsed = false;
-      element.unresolved = false;
-      element._setInitialExpandedState();
-      assert.isFalse(element.comments[0].collapsed);
-      for (let i = 1; i < element.comments.length; i++) {
-        assert.isTrue(element.comments[i].collapsed);
-      }
-    });
-  });
-
-  test('_computeHostClass', () => {
-    assert.equal(element._computeHostClass(true), 'unresolved');
-    assert.equal(element._computeHostClass(false), '');
-  });
-
-  test('addDraft sets unresolved state correctly', () => {
-    let unresolved = true;
-    element.comments = [];
-    element.addDraft(null, null, unresolved);
-    assert.equal(element.comments[0].unresolved, true);
-
-    unresolved = false; // comment should get added as actually resolved.
-    element.comments = [];
-    element.addDraft(null, null, unresolved);
-    assert.equal(element.comments[0].unresolved, false);
-
-    element.comments = [];
-    element.addDraft();
-    assert.equal(element.comments[0].unresolved, true);
-  });
-
-  test('_newDraft with root', () => {
-    const draft = element._newDraft();
-    assert.equal(draft.__commentSide, 'left');
-    assert.equal(draft.patch_set, 3);
-  });
-
-  test('_newDraft with no root', () => {
-    element.comments = [];
-    element.commentSide = 'right';
-    element.patchNum = 2;
-    const draft = element._newDraft();
-    assert.equal(draft.__commentSide, 'right');
-    assert.equal(draft.patch_set, 2);
-  });
-
-  test('new comment gets created', () => {
-    element.comments = [];
-    element.addOrEditDraft(1);
-    assert.equal(element.comments.length, 1);
-    // Mock a submitted comment.
-    element.comments[0].id = element.comments[0].__draftID;
-    element.comments[0].__draft = false;
-    element.addOrEditDraft(1);
-    assert.equal(element.comments.length, 2);
-  });
-
-  test('unresolved label', () => {
-    element.unresolved = false;
-    assert.isTrue(element.$.unresolvedLabel.hasAttribute('hidden'));
-    element.unresolved = true;
-    assert.isFalse(element.$.unresolvedLabel.hasAttribute('hidden'));
-  });
-
-  test('draft comments are at the end of orderedComments', () => {
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 2,
-      line: 5,
-      message: 'Earlier draft',
-      updated: '2015-12-08 19:48:33.843000000',
-      __draft: true,
-    },
-    {
-      author: {
-        name: 'Mr. Peanutbutter2',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 1,
-      line: 5,
-      message: 'This comment was left last but is not a draft',
-      updated: '2015-12-10 19:48:33.843000000',
-    },
-    {
-      author: {
-        name: 'Mr. Peanutbutter2',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 3,
-      line: 5,
-      message: 'Later draft',
-      updated: '2015-12-09 19:48:33.843000000',
-      __draft: true,
-    }];
-    assert.equal(element._orderedComments[0].id, '1');
-    assert.equal(element._orderedComments[1].id, '2');
-    assert.equal(element._orderedComments[2].id, '3');
-  });
-
-  test('reflects lineNum and commentSide to attributes', () => {
-    element.lineNum = 7;
-    element.commentSide = 'left';
-
-    assert.equal(element.getAttribute('line-num'), '7');
-    assert.equal(element.getAttribute('comment-side'), 'left');
-  });
-
-  test('reflects range to JSON serialized attribute if set', () => {
-    element.range = {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    };
-
-    assert.deepEqual(
-        JSON.parse(element.getAttribute('range')),
-        {start_line: 4, end_line: 5, start_character: 6, end_character: 7});
-  });
-
-  test('removes range attribute if range is unset', () => {
-    element.range = {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    };
-    element.range = undefined;
-
-    assert.notOk(element.hasAttribute('range'));
-  });
-});
-
-suite('comment action tests on resolved comments', () => {
-  let element;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      saveDiffDraft() {
-        return Promise.resolve({
-          ok: true,
-          text() {
-            return Promise.resolve(')]}\'\n' +
-                JSON.stringify({
-                  id: '7afa4931_de3d65bd',
-                  path: '/path/to/file.txt',
-                  line: 5,
-                  in_reply_to: 'baf0414d_60047215',
-                  updated: '2015-12-21 02:01:10.850000000',
-                  message: 'Done',
-                }));
-          },
-        });
-      },
-      deleteDiffDraft() { return Promise.resolve({ok: true}); },
-    });
-    element = withCommentFixture.instantiate();
-    element.patchNum = '1';
-    element.changeNum = '1';
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      line: 5,
-      message: 'is this a crossover episode!?',
-      updated: '2015-12-08 19:48:33.843000000',
-      path: '/path/to/file.txt',
-      unresolved: false,
-    }];
-    flush();
-  });
-
-  test('ack and done should be hidden', () => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const ackBtn = element.shadowRoot.querySelector('#ackBtn');
-    const doneBtn = element.shadowRoot.querySelector('#doneBtn');
-    assert.equal(ackBtn, null);
-    assert.equal(doneBtn, null);
-  });
-
-  test('reply and quote button should be visible', () => {
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const replyBtn = element.shadowRoot.querySelector('#replyBtn');
-    const quoteBtn = element.shadowRoot.querySelector('#quoteBtn');
-    assert.ok(replyBtn);
-    assert.ok(quoteBtn);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
new file mode 100644
index 0000000..64f5ad8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -0,0 +1,986 @@
+/**
+ * @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-thread';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {SpecialFilePath, Side} from '../../../constants/constants';
+import {
+  sortComments,
+  UIComment,
+  UIRobot,
+  isDraft,
+  UIDraft,
+} from '../../../utils/comment-util';
+import {GrCommentThread} from './gr-comment-thread';
+import {
+  PatchSetNum,
+  NumericChangeId,
+  UrlEncodedCommentId,
+  Timestamp,
+  RobotId,
+  RobotRunId,
+  RepoName,
+  ConfigInfo,
+  EmailAddress,
+} from '../../../types/common';
+import {GrComment} from '../gr-comment/gr-comment';
+import {LineNumber} from '../../diff/gr-diff/gr-diff-line';
+import {
+  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';
+
+const basicFixture = fixtureFromElement('gr-comment-thread');
+
+const withCommentFixture = fixtureFromElement('gr-comment-thread');
+
+suite('gr-comment-thread tests', () => {
+  suite('basic test', () => {
+    let element: GrCommentThread;
+
+    setup(() => {
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+
+      element = basicFixture.instantiate();
+      element.patchNum = 3 as PatchSetNum;
+      element.changeNum = 1 as NumericChangeId;
+      flush();
+    });
+
+    test('renders without patchNum and changeNum', async () => {
+      const fixture = fixtureFromTemplate(
+        html`<gr-comment-thread show-file-path="" path="path/to/file"></gr-change-metadata>`
+      );
+      fixture.instantiate();
+      await flush();
+    });
+
+    test('comments are sorted correctly', () => {
+      const comments: UIComment[] = [
+        {
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+          __date: new Date('2015-12-25'),
+        },
+        {
+          id: 'sallys_confession' as UrlEncodedCommentId,
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_defiance' as UrlEncodedCommentId,
+          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'dr_finklesteins_response' as UrlEncodedCommentId,
+          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_mission' as UrlEncodedCommentId,
+          message: 'i have to find santa',
+          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+        },
+      ];
+      const results = sortComments(comments);
+      assert.deepEqual(results, [
+        {
+          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'dr_finklesteins_response' as UrlEncodedCommentId,
+          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_defiance' as UrlEncodedCommentId,
+          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_confession' as UrlEncodedCommentId,
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_mission' as UrlEncodedCommentId,
+          message: 'i have to find santa',
+          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+        },
+        {
+          message: 'i like you, too' as UrlEncodedCommentId,
+          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+          __date: new Date('2015-12-25'),
+        },
+      ]);
+    });
+
+    test('addOrEditDraft w/ edit draft', () => {
+      element.comments = [
+        {
+          id: 'jacks_reply' as UrlEncodedCommentId,
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+          updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+          __draft: true,
+        },
+      ];
+      const commentElStub = sinon
+        .stub(element, '_commentElWithDraftID')
+        .callsFake(() => new GrComment());
+      const addDraftStub = sinon.stub(element, 'addDraft');
+
+      element.addOrEditDraft(123);
+
+      assert.isTrue(commentElStub.called);
+      assert.isFalse(addDraftStub.called);
+    });
+
+    test('addOrEditDraft w/o edit draft', () => {
+      element.comments = [];
+      const commentElStub = sinon
+        .stub(element, '_commentElWithDraftID')
+        .callsFake(() => new GrComment());
+      const addDraftStub = sinon.stub(element, 'addDraft');
+
+      element.addOrEditDraft(123);
+
+      assert.isFalse(commentElStub.called);
+      assert.isTrue(addDraftStub.called);
+    });
+
+    test('_shouldDisableAction', () => {
+      let showActions = true;
+      const lastComment: UIComment = {};
+      assert.equal(
+        element._shouldDisableAction(showActions, lastComment),
+        false
+      );
+      showActions = false;
+      assert.equal(
+        element._shouldDisableAction(showActions, lastComment),
+        true
+      );
+      showActions = true;
+      lastComment.__draft = true;
+      assert.equal(
+        element._shouldDisableAction(showActions, lastComment),
+        true
+      );
+      const robotComment: UIRobot = {
+        id: '1234' as UrlEncodedCommentId,
+        updated: '1234' as Timestamp,
+        robot_id: 'robot_id' as RobotId,
+        robot_run_id: 'robot_run_id' as RobotRunId,
+        properties: {},
+        fix_suggestions: [],
+      };
+      assert.equal(
+        element._shouldDisableAction(showActions, robotComment),
+        false
+      );
+    });
+
+    test('_hideActions', () => {
+      let showActions = true;
+      const lastComment: UIComment = {};
+      assert.equal(element._hideActions(showActions, lastComment), false);
+      showActions = false;
+      assert.equal(element._hideActions(showActions, lastComment), true);
+      showActions = true;
+      lastComment.__draft = true;
+      assert.equal(element._hideActions(showActions, lastComment), true);
+      const robotComment: UIRobot = {
+        id: '1234' as UrlEncodedCommentId,
+        updated: '1234' as Timestamp,
+        robot_id: 'robot_id' as RobotId,
+        robot_run_id: 'robot_run_id' as RobotRunId,
+        properties: {},
+        fix_suggestions: [],
+      };
+      assert.equal(element._hideActions(showActions, robotComment), true);
+    });
+
+    test('setting project name loads the project config', done => {
+      const projectName = 'foo/bar/baz' as RepoName;
+      const getProjectStub = stubRestApi('getProjectConfig').returns(
+        Promise.resolve({} as ConfigInfo)
+      );
+      element.projectName = projectName;
+      flush(() => {
+        assert.isTrue(getProjectStub.calledWithExactly(projectName as never));
+        done();
+      });
+    });
+
+    test('optionally show file path', () => {
+      // Path info doesn't exist when showFilePath is false. Because it's in a
+      // dom-if it is not yet in the dom.
+      assert.isNotOk(element.shadowRoot?.querySelector('.pathInfo'));
+
+      const commentStub = sinon.stub(GerritNav, 'getUrlForComment');
+      element.changeNum = 123 as NumericChangeId;
+      element.projectName = 'test project' as RepoName;
+      element.path = 'path/to/file';
+      element.patchNum = 3 as PatchSetNum;
+      element.lineNum = 5;
+      element.comments = [{id: 'comment_id' as UrlEncodedCommentId}];
+      element.showFilePath = true;
+      flush();
+      assert.isOk(element.shadowRoot?.querySelector('.pathInfo'));
+      assert.notEqual(
+        getComputedStyle(element.shadowRoot!.querySelector('.pathInfo')!)
+          .display,
+        'none'
+      );
+      assert.isTrue(
+        commentStub.calledWithExactly(
+          element.changeNum,
+          element.projectName,
+          'comment_id' as UrlEncodedCommentId
+        )
+      );
+    });
+
+    test('_computeDisplayPath', () => {
+      let path = 'path/to/file';
+      assert.equal(element._computeDisplayPath(path), 'path/to/file');
+
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayPath(path), 'path/to/file');
+
+      element.patchNum = 3 as PatchSetNum;
+      path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+      assert.equal(element._computeDisplayPath(path), 'Patchset');
+    });
+
+    test('_computeDisplayLine', () => {
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayLine(), '#5');
+
+      element.path = SpecialFilePath.COMMIT_MESSAGE;
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayLine(), '#5');
+
+      element.lineNum = undefined;
+      element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+      assert.equal(element._computeDisplayLine(), '');
+    });
+  });
+});
+
+suite('comment action tests with unresolved thread', () => {
+  let element: GrCommentThread;
+
+  setup(() => {
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('saveDiffDraft').returns(
+      Promise.resolve(({
+        headers: {} as Headers,
+        redirected: false,
+        status: 200,
+        statusText: '',
+        type: '' as ResponseType,
+        url: '',
+        ok: true,
+        text() {
+          return Promise.resolve(
+            ")]}'\n" +
+              JSON.stringify({
+                id: '7afa4931_de3d65bd',
+                path: '/path/to/file.txt',
+                line: 5,
+                in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
+                updated: '2015-12-21 02:01:10.850000000',
+                message: 'Done',
+              })
+          );
+        },
+      } as unknown) as Response)
+    );
+    stubRestApi('deleteDiffDraft').returns(
+      Promise.resolve(({ok: true} as unknown) as Response)
+    );
+    element = withCommentFixture.instantiate();
+    element.patchNum = 1 as PatchSetNum;
+    element.changeNum = 1 as NumericChangeId;
+    element.comments = [
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: ('tenn1sballchaser@aol.com' as EmailAddress) as EmailAddress,
+        },
+        id: 'baf0414d_60047215' as UrlEncodedCommentId,
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+        path: '/path/to/file.txt',
+        unresolved: true,
+        patch_set: 3 as PatchSetNum,
+      },
+    ];
+    flush();
+  });
+
+  test('reply', () => {
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    assert.ok(commentEl);
+
+    const replyBtn = element.$.replyBtn;
+    tap(replyBtn);
+    flush();
+
+    const drafts = element._orderedComments.filter(c => isDraft(c));
+    assert.equal(drafts.length, 1);
+    assert.notOk(drafts[0].message, 'message should be empty');
+    assert.equal(
+      drafts[0].in_reply_to,
+      ('baf0414d_60047215' as UrlEncodedCommentId) as UrlEncodedCommentId
+    );
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('quote reply', () => {
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    assert.ok(commentEl);
+
+    const quoteBtn = element.$.quoteBtn;
+    tap(quoteBtn);
+    flush();
+
+    const drafts = element._orderedComments.filter(c => isDraft(c));
+    assert.equal(drafts.length, 1);
+    assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
+    assert.equal(
+      drafts[0].in_reply_to,
+      ('baf0414d_60047215' as UrlEncodedCommentId) as UrlEncodedCommentId
+    );
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('quote reply multiline', () => {
+    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    element.comments = [
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: ('tenn1sballchaser@aol.com' as EmailAddress) as EmailAddress,
+        },
+        id: 'baf0414d_60047215' as UrlEncodedCommentId,
+        path: 'test',
+        line: 5,
+        message: 'is this a crossover episode!?\nIt might be!',
+        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+      },
+    ];
+    flush();
+
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const quoteBtn = element.$.quoteBtn;
+    tap(quoteBtn);
+    flush();
+
+    const drafts = element._orderedComments.filter(c => isDraft(c));
+    assert.equal(drafts.length, 1);
+    assert.equal(
+      drafts[0].message,
+      '> is this a crossover episode!?\n> It might be!\n\n'
+    );
+    assert.equal(
+      drafts[0].in_reply_to,
+      'baf0414d_60047215' as UrlEncodedCommentId
+    );
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('ack', done => {
+    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
+    assert.isOk(ackBtn);
+    tap(ackBtn!);
+    flush(() => {
+      const drafts = element.comments.filter(c => isDraft(c));
+      assert.equal(drafts.length, 1);
+      assert.equal(drafts[0].message, 'Ack');
+      assert.equal(
+        drafts[0].in_reply_to,
+        'baf0414d_60047215' as UrlEncodedCommentId
+      );
+      assert.equal(drafts[0].unresolved, false);
+      assert.isTrue(reportStub.calledOnce);
+      done();
+    });
+  });
+
+  test('done', done => {
+    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
+    assert.isOk(doneBtn);
+    tap(doneBtn!);
+    flush(() => {
+      const drafts = element.comments.filter(c => isDraft(c));
+      assert.equal(drafts.length, 1);
+      assert.equal(drafts[0].message, 'Done');
+      assert.equal(
+        drafts[0].in_reply_to,
+        'baf0414d_60047215' as UrlEncodedCommentId
+      );
+      assert.isFalse(drafts[0].unresolved);
+      assert.isTrue(reportStub.calledOnce);
+      done();
+    });
+  });
+
+  test('save', done => {
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+    element.path = '/path/to/file.txt';
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const saveOrDiscardStub = sinon.stub();
+    element.addEventListener('thread-changed', saveOrDiscardStub);
+    element.shadowRoot?.querySelector('gr-comment')?._fireSave();
+
+    flush(() => {
+      assert.isTrue(saveOrDiscardStub.called);
+      assert.equal(
+        saveOrDiscardStub.lastCall.args[0].detail.rootId,
+        'baf0414d_60047215'
+      );
+      assert.equal(element.rootId, 'baf0414d_60047215' as UrlEncodedCommentId);
+      assert.equal(
+        saveOrDiscardStub.lastCall.args[0].detail.path,
+        '/path/to/file.txt'
+      );
+      done();
+    });
+  });
+
+  test('please fix', done => {
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+    commentEl!.addEventListener('create-fix-comment', () => {
+      const drafts = element._orderedComments.filter(c => isDraft(c));
+      assert.equal(drafts.length, 1);
+      assert.equal(
+        drafts[0].message,
+        '> is this a crossover episode!?\n\nPlease fix.'
+      );
+      assert.equal(
+        drafts[0].in_reply_to,
+        'baf0414d_60047215' as UrlEncodedCommentId
+      );
+      assert.isTrue(drafts[0].unresolved);
+      done();
+    });
+    commentEl!.dispatchEvent(
+      new CustomEvent('create-fix-comment', {
+        detail: {comment: commentEl!.comment},
+        composed: true,
+        bubbles: false,
+      })
+    );
+  });
+
+  test('discard', done => {
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+    element.path = '/path/to/file.txt';
+    assert.isOk(element.comments[0]);
+    element.push(
+      'comments',
+      element._newReply(
+        element.comments[0]!.id as UrlEncodedCommentId,
+        'it’s pronouced jiff, not giff'
+      )
+    );
+    flush();
+
+    const saveOrDiscardStub = sinon.stub();
+    element.addEventListener('thread-changed', saveOrDiscardStub);
+    const draftEl = element.root?.querySelectorAll('gr-comment')[1];
+    assert.ok(draftEl);
+    draftEl!.addEventListener('comment-discard', () => {
+      const drafts = element.comments.filter(c => isDraft(c));
+      assert.equal(drafts.length, 0);
+      assert.isTrue(saveOrDiscardStub.called);
+      assert.equal(
+        saveOrDiscardStub.lastCall.args[0].detail.rootId,
+        element.rootId
+      );
+      assert.equal(
+        saveOrDiscardStub.lastCall.args[0].detail.path,
+        element.path
+      );
+      done();
+    });
+    draftEl!.dispatchEvent(
+      new CustomEvent('comment-discard', {
+        detail: {comment: draftEl!.comment},
+        composed: true,
+        bubbles: false,
+      })
+    );
+  });
+
+  test('discard with a single comment still fires event with previous rootId', done => {
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+    element.path = '/path/to/file.txt';
+    element.comments = [];
+    element.addOrEditDraft(1 as LineNumber);
+    flush();
+    const rootId = element.rootId;
+    assert.isOk(rootId);
+
+    const saveOrDiscardStub = sinon.stub();
+    element.addEventListener('thread-changed', saveOrDiscardStub);
+    const draftEl = element.root?.querySelectorAll('gr-comment')[0];
+    assert.ok(draftEl);
+    draftEl!.addEventListener('comment-discard', () => {
+      assert.equal(element.comments.length, 0);
+      assert.isTrue(saveOrDiscardStub.called);
+      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId, rootId);
+      assert.equal(
+        saveOrDiscardStub.lastCall.args[0].detail.path,
+        element.path
+      );
+      done();
+    });
+    draftEl!.dispatchEvent(
+      new CustomEvent('comment-discard', {
+        detail: {comment: draftEl!.comment},
+        composed: true,
+        bubbles: false,
+      })
+    );
+  });
+
+  test(
+    'When not editing other comments, local storage not set' + ' after discard',
+    done => {
+      element.changeNum = 42 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      element.comments = [
+        {
+          author: {
+            name: 'Mr. Peanutbutter',
+            email: 'tenn1sballchaser@aol.com' as EmailAddress,
+          },
+          id: 'baf0414d_60047215' as UrlEncodedCommentId,
+          path: 'test',
+          line: 5,
+          message: 'is this a crossover episode!?',
+          updated: '2015-12-08 19:48:31.843000000' as Timestamp,
+        },
+        {
+          author: {
+            name: 'Mr. Peanutbutter',
+            email: 'tenn1sballchaser@aol.com' as EmailAddress,
+          },
+          __draftID: '1',
+          in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
+          path: 'test',
+          line: 5,
+          message: 'yes',
+          updated: '2015-12-08 19:48:32.843000000' as Timestamp,
+          __draft: true,
+          __editing: true,
+        },
+        {
+          author: {
+            name: 'Mr. Peanutbutter',
+            email: 'tenn1sballchaser@aol.com' as EmailAddress,
+          },
+          __draftID: '2',
+          in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
+          path: 'test',
+          line: 5,
+          message: 'no',
+          updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+          __draft: true,
+        },
+      ];
+      const storageStub = 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 = {
+      id: element.comments[0].id,
+      foo: 'bar',
+    };
+    assert.isOk(commentEl);
+    commentEl!.dispatchEvent(
+      new CustomEvent('comment-update', {
+        detail: {comment: updatedComment},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.strictEqual(element.comments[0], updatedComment);
+  });
+
+  suite('jack and sally comment data test consolidation', () => {
+    setup(() => {
+      element.comments = [
+        {
+          id: 'jacks_reply' as UrlEncodedCommentId,
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+          updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+          unresolved: false,
+        },
+        {
+          id: 'sallys_confession' as UrlEncodedCommentId,
+          in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_defiance' as UrlEncodedCommentId,
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
+        },
+      ];
+    });
+
+    test('orphan replies', () => {
+      assert.equal(4, element._orderedComments.length);
+    });
+
+    test('keyboard shortcuts', () => {
+      const expandCollapseStub = sinon.stub(element, '_expandCollapseComments');
+      pressAndReleaseKeyOn(element, 69, null, 'e');
+      assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
+
+      pressAndReleaseKeyOn(element, 69, 'shift', 'e');
+      assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
+    });
+
+    test('comment in_reply_to is either null or most recent comment', () => {
+      element._createReplyComment('dummy', true);
+      flush();
+      assert.equal(element._orderedComments.length, 5);
+      assert.equal(
+        element._orderedComments[4].in_reply_to,
+        'jacks_reply' as UrlEncodedCommentId
+      );
+    });
+
+    test('resolvable comments', () => {
+      assert.isFalse(element.unresolved);
+      element._createReplyComment('dummy', true, true);
+      flush();
+      assert.isTrue(element.unresolved);
+    });
+
+    test('_setInitialExpandedState with unresolved', () => {
+      element.unresolved = true;
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isFalse(element.comments[i].collapsed);
+      }
+    });
+
+    test('_setInitialExpandedState without unresolved', () => {
+      element.unresolved = false;
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isTrue(element.comments[i].collapsed);
+      }
+    });
+
+    test('_setInitialExpandedState with robot_ids', () => {
+      for (let i = 0; i < element.comments.length; i++) {
+        (element.comments[i] as UIRobot).robot_id = '123' as RobotId;
+      }
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isFalse(element.comments[i].collapsed);
+      }
+    });
+
+    test('_setInitialExpandedState with collapsed state', () => {
+      element.comments[0].collapsed = false;
+      element.unresolved = false;
+      element._setInitialExpandedState();
+      assert.isFalse(element.comments[0].collapsed);
+      for (let i = 1; i < element.comments.length; i++) {
+        assert.isTrue(element.comments[i].collapsed);
+      }
+    });
+  });
+
+  test('_computeHostClass', () => {
+    assert.equal(element._computeHostClass(true), 'unresolved');
+    assert.equal(element._computeHostClass(false), '');
+  });
+
+  test('addDraft sets unresolved state correctly', () => {
+    let unresolved = true;
+    element.comments = [];
+    element.addDraft(undefined, undefined, unresolved);
+    assert.equal(element.comments[0].unresolved, true);
+
+    unresolved = false; // comment should get added as actually resolved.
+    element.comments = [];
+    element.addDraft(undefined, undefined, unresolved);
+    assert.equal(element.comments[0].unresolved, false);
+
+    element.comments = [];
+    element.addDraft();
+    assert.equal(element.comments[0].unresolved, true);
+  });
+
+  test('_newDraft with root', () => {
+    const draft = element._newDraft();
+    assert.equal(draft.patch_set, 3 as PatchSetNum);
+  });
+
+  test('_newDraft with no root', () => {
+    element.comments = [];
+    element.diffSide = Side.RIGHT;
+    element.patchNum = 2 as PatchSetNum;
+    const draft = element._newDraft();
+    assert.equal(draft.patch_set, 2 as PatchSetNum);
+  });
+
+  test('new comment gets created', () => {
+    element.comments = [];
+    element.addOrEditDraft(1);
+    assert.equal(element.comments.length, 1);
+    // Mock a submitted comment.
+    element.comments[0].id = (element.comments[0] as UIDraft)
+      .__draftID as UrlEncodedCommentId;
+    delete (element.comments[0] as UIDraft).__draft;
+    element.addOrEditDraft(1);
+    assert.equal(element.comments.length, 2);
+  });
+
+  test('unresolved label', () => {
+    element.unresolved = false;
+    const label = element.shadowRoot?.querySelector('#unresolvedLabel');
+    assert.isOk(label);
+    assert.isFalse(label!.hasAttribute('hidden'));
+    element.unresolved = true;
+    assert.isFalse(label!.hasAttribute('hidden'));
+  });
+
+  test('draft comments are at the end of orderedComments', () => {
+    element.comments = [
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        id: '2' as UrlEncodedCommentId,
+        line: 5,
+        message: 'Earlier draft',
+        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+        __draft: true,
+      },
+      {
+        author: {
+          name: 'Mr. Peanutbutter2',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        id: '1' as UrlEncodedCommentId,
+        line: 5,
+        message: 'This comment was left last but is not a draft',
+        updated: '2015-12-10 19:48:33.843000000' as Timestamp,
+      },
+      {
+        author: {
+          name: 'Mr. Peanutbutter2',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        id: '3' as UrlEncodedCommentId,
+        line: 5,
+        message: 'Later draft',
+        updated: '2015-12-09 19:48:33.843000000' as Timestamp,
+        __draft: true,
+      },
+    ];
+    assert.equal(element._orderedComments[0].id, '1' as UrlEncodedCommentId);
+    assert.equal(element._orderedComments[1].id, '2' as UrlEncodedCommentId);
+    assert.equal(element._orderedComments[2].id, '3' as UrlEncodedCommentId);
+  });
+
+  test('reflects lineNum and commentSide to attributes', () => {
+    element.lineNum = 7;
+    element.diffSide = Side.LEFT;
+
+    assert.equal(element.getAttribute('line-num'), '7');
+    assert.equal(element.getAttribute('diff-side'), Side.LEFT);
+  });
+
+  test('reflects range to JSON serialized attribute if set', () => {
+    element.range = {
+      start_line: 4,
+      end_line: 5,
+      start_character: 6,
+      end_character: 7,
+    };
+
+    assert.isOk(element.getAttribute('range'));
+    assert.deepEqual(JSON.parse(element.getAttribute('range')!), {
+      start_line: 4,
+      end_line: 5,
+      start_character: 6,
+      end_character: 7,
+    });
+  });
+
+  test('removes range attribute if range is unset', () => {
+    element.range = {
+      start_line: 4,
+      end_line: 5,
+      start_character: 6,
+      end_character: 7,
+    };
+    element.range = undefined;
+
+    assert.notOk(element.hasAttribute('range'));
+  });
+});
+
+suite('comment action tests on resolved comments', () => {
+  let element: GrCommentThread;
+
+  setup(() => {
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('saveDiffDraft').returns(
+      Promise.resolve(({
+        ok: true,
+        text() {
+          return Promise.resolve(
+            ")]}'\n" +
+              JSON.stringify({
+                id: '7afa4931_de3d65bd',
+                path: '/path/to/file.txt',
+                line: 5,
+                in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
+                updated: '2015-12-21 02:01:10.850000000',
+                message: 'Done',
+              })
+          );
+        },
+      } as unknown) as Response)
+    );
+    stubRestApi('deleteDiffDraft').returns(
+      Promise.resolve(({ok: true} as unknown) as Response)
+    );
+    element = withCommentFixture.instantiate();
+    element.patchNum = 1 as PatchSetNum;
+    element.changeNum = 1 as NumericChangeId;
+    element.comments = [
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        id: 'baf0414d_60047215' as UrlEncodedCommentId,
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+        path: '/path/to/file.txt',
+        unresolved: false,
+      },
+    ];
+    flush();
+  });
+
+  test('ack and done should be hidden', () => {
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
+    const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
+    assert.equal(ackBtn, null);
+    assert.equal(doneBtn, null);
+  });
+
+  test('reply and quote button should be visible', () => {
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const replyBtn = element.shadowRoot?.querySelector('#replyBtn');
+    const quoteBtn = element.shadowRoot?.querySelector('#quoteBtn');
+    assert.ok(replyBtn);
+    assert.ok(quoteBtn);
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index e4d520f..b7f1bcc 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -24,35 +24,31 @@
 import '../gr-formatted-text/gr-formatted-text';
 import '../gr-icons/gr-icons';
 import '../gr-overlay/gr-overlay';
-import '../gr-rest-api-interface/gr-rest-api-interface';
-import '../gr-storage/gr-storage';
 import '../gr-textarea/gr-textarea';
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import '../gr-account-label/gr-account-label';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-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';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {GrTextarea} from '../gr-textarea/gr-textarea';
-import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
 import {GrOverlay} from '../gr-overlay/gr-overlay';
 import {
   AccountDetailInfo,
   NumericChangeId,
   ConfigInfo,
   PatchSetNum,
+  RepoName,
+  BasePatchSetNum,
 } from '../../../types/common';
 import {GrButton} from '../gr-button/gr-button';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import {GrDialog} from '../gr-dialog/gr-dialog';
-import {Side} from '../../../constants/constants';
 import {
   isDraft,
   UIComment,
@@ -60,13 +56,15 @@
   UIRobot,
 } from '../../../utils/comment-util';
 import {OpenFixPreviewEventDetail} from '../../../types/events';
+import {fireAlert} from '../../../utils/event-util';
+import {pluralize} from '../../../utils/string-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {debounce, DelayedTask} from '../../../utils/async-util';
+import {StorageLocation} from '../../../services/storage/gr-storage';
 
 const STORAGE_DEBOUNCE_INTERVAL = 400;
 const TOAST_DEBOUNCE_INTERVAL = 200;
 
-const SAVING_MESSAGE = 'Saving';
-const DRAFT_SINGULAR = 'draft...';
-const DRAFT_PLURAL = 'drafts...';
 const SAVED_MESSAGE = 'All changes saved';
 const UNSAVED_MESSAGE = 'Unable to save draft';
 
@@ -97,17 +95,13 @@
 
 export interface GrComment {
   $: {
-    restAPI: RestApiService & Element;
-    storage: GrStorage;
     container: HTMLDivElement;
     resolvedCheckbox: HTMLInputElement;
   };
 }
 
 @customElement('gr-comment')
-export class GrComment extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrComment extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -157,11 +151,14 @@
   @property({type: Number})
   changeNum?: NumericChangeId;
 
+  @property({type: String})
+  projectName?: RepoName;
+
   @property({type: Object, notify: true, observer: '_commentChanged'})
-  comment?: UIComment | UIRobot;
+  comment?: UIComment;
 
   @property({type: Array})
-  comments?: (UIComment | UIRobot)[];
+  comments?: UIComment[];
 
   @property({type: Boolean, reflectToAttribute: true})
   isRobotComment = false;
@@ -213,15 +210,13 @@
   _isAdmin = false;
 
   @property({type: Object})
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   _xhrPromise?: Promise<any>; // Used for testing.
 
   @property({type: String, observer: '_messageTextChanged'})
   _messageText = '';
 
   @property({type: String})
-  commentSide?: Side;
-
-  @property({type: String})
   side?: string;
 
   @property({type: Boolean})
@@ -261,6 +256,9 @@
   @property({type: Object})
   _selfAccount?: AccountDetailInfo;
 
+  @property({type: Boolean})
+  showPortedComment = false;
+
   get keyBindings() {
     return {
       'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
@@ -268,12 +266,22 @@
     };
   }
 
+  private readonly restApiService = appContext.restApiService;
+
+  private readonly storage = appContext.storageService;
+
   reporting = appContext.reportingService;
 
+  private fireUpdateTask?: DelayedTask;
+
+  private storeTask?: DelayedTask;
+
+  private draftToastTask?: DelayedTask;
+
   /** @override */
-  attached() {
-    super.attached();
-    this.$.restAPI.getAccount().then(account => {
+  connectedCallback() {
+    super.connectedCallback();
+    this.restApiService.getAccount().then(account => {
       this._selfAccount = account;
     });
     if (this.editing) {
@@ -287,18 +295,38 @@
   }
 
   /** @override */
-  detached() {
-    super.detached();
-    this.cancelDebouncer('fire-update');
+  disconnectedCallback() {
+    this.fireUpdateTask?.cancel();
+    this.storeTask?.cancel();
+    this.draftToastTask?.cancel();
     if (this.textarea) {
       this.textarea.closeDropdown();
     }
+    super.disconnectedCallback();
   }
 
   _getAuthor(comment: UIComment) {
     return comment.author || this._selfAccount;
   }
 
+  _getUrlForComment(comment: UIComment) {
+    if (!this.changeNum || !this.projectName) return '';
+    if (!comment.id) throw new Error('comment must have an id');
+    return GerritNav.getUrlForComment(
+      this.changeNum as NumericChangeId,
+      this.projectName,
+      comment.id
+    );
+  }
+
+  _handlePortedMessageClick() {
+    assertIsDefined(this.comment, 'comment');
+    this.reporting.reportInteraction('navigate-to-original-comment', {
+      line: this.comment.line,
+      range: this.comment.range,
+    });
+  }
+
   @observe('editing')
   _onEditingChange(editing?: boolean) {
     this.dispatchEvent(
@@ -311,7 +339,7 @@
     if (!editing) return;
     // visibility based on cache this will make sure we only and always show
     // a tip once every Math.max(a day, period between creating comments)
-    const cachedVisibilityOfRespectfulTip = this.$.storage.getRespectfulTipVisibility();
+    const cachedVisibilityOfRespectfulTip = this.storage.getRespectfulTipVisibility();
     if (!cachedVisibilityOfRespectfulTip) {
       // we still want to show the tip with a probability of 30%
       if (this.getRandomNum(0, 3) >= 1) return;
@@ -322,7 +350,7 @@
         tip: this._respectfulReviewTip,
       });
       // update cache
-      this.$.storage.setRespectfulTipVisibility();
+      this.storage.setRespectfulTipVisibility();
     }
   }
 
@@ -341,7 +369,7 @@
       tip: this._respectfulReviewTip,
     });
     // add a 14-day delay to the tip cache
-    this.$.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
+    this.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
   }
 
   _onRespectfulReadMoreClick() {
@@ -403,7 +431,7 @@
   }
 
   _getIsAdmin() {
-    return this.$.restAPI.getIsAdmin();
+    return this.restApiService.getIsAdmin();
   }
 
   _computeDraftTooltip(unableToSave: boolean) {
@@ -439,7 +467,7 @@
         }
 
         this._eraseDraftComment();
-        return this.$.restAPI.getResponseObject(response).then(obj => {
+        return this.restApiService.getResponseObject(response).then(obj => {
           const resComment = (obj as unknown) as UIDraft;
           if (!isDraft(this.comment)) throw new Error('Can only save drafts.');
           resComment.__draft = true;
@@ -448,7 +476,6 @@
           if (this.comment?.__draftID) {
             resComment.__draftID = this.comment.__draftID;
           }
-          resComment.__commentSide = this.commentSide;
           this.comment = resComment;
           this._fireSave();
           return obj;
@@ -465,13 +492,11 @@
   _eraseDraftComment() {
     // Prevents a race condition in which removing the draft comment occurs
     // prior to it being saved.
-    this.cancelDebouncer('store');
+    this.storeTask?.cancel();
 
-    if (!this.comment?.path) throw new Error('Cannot erase Draft Comment');
-    if (this.changeNum === undefined) {
-      throw new Error('undefined changeNum');
-    }
-    this.$.storage.eraseDraftComment({
+    assertIsDefined(this.comment?.path, 'comment.path');
+    assertIsDefined(this.changeNum, 'changeNum');
+    this.storage.eraseDraftComment({
       changeNum: this.changeNum,
       patchNum: this._getPatchNum(),
       path: this.comment.path,
@@ -481,7 +506,7 @@
   }
 
   _commentChanged(comment: UIComment) {
-    this.editing = !!comment.__editing;
+    this.editing = isDraft(comment) && !!comment.__editing;
     this.resolved = !comment.unresolved;
     if (this.editing) {
       // It's a new draft/reply, notify.
@@ -517,7 +542,7 @@
   }
 
   _fireUpdate() {
-    this.debounce('fire-update', () => {
+    this.fireUpdateTask = debounce(this.fireUpdateTask, () => {
       this.dispatchEvent(
         new CustomEvent('comment-update', {
           detail: this._getEventPayload(),
@@ -551,7 +576,7 @@
         cancelButton.hidden = !editing;
       }
     }
-    if (this.comment) {
+    if (isDraft(this.comment)) {
       this.comment.__editing = this.editing;
     }
     if (!!editing !== !!previousValue) {
@@ -559,7 +584,7 @@
       this._fireUpdate();
     }
     if (editing) {
-      this.async(() => {
+      setTimeout(() => {
         flush();
         this.textarea && this.textarea.putCursorAtEnd();
       }, 1);
@@ -625,8 +650,8 @@
       : this._getPatchNum();
     const {path, line, range} = this.comment;
     if (path) {
-      this.debounce(
-        'store',
+      this.storeTask = debounce(
+        this.storeTask,
         () => {
           const message = this._messageText;
           if (this.changeNum === undefined) {
@@ -643,9 +668,9 @@
           if ((!message || !message.length) && oldValue) {
             // If the draft has been modified to be empty, then erase the storage
             // entry.
-            this.$.storage.eraseDraftComment(commentLocation);
+            this.storage.eraseDraftComment(commentLocation);
           } else {
-            this.$.storage.setDraftComment(commentLocation, message);
+            this.storage.setDraftComment(commentLocation, message);
           }
         },
         STORAGE_DEBOUNCE_INTERVAL
@@ -708,7 +733,7 @@
   }
 
   _fireDiscard() {
-    this.cancelDebouncer('fire-update');
+    this.fireUpdateTask?.cancel();
     this.dispatchEvent(
       new CustomEvent('comment-discard', {
         detail: this._getEventPayload(),
@@ -813,11 +838,7 @@
     if (numPending === 0) {
       return SAVED_MESSAGE;
     }
-    return [
-      SAVING_MESSAGE,
-      numPending,
-      numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
-    ].join(' ');
+    return `Saving ${pluralize(numPending, 'draft')}...`;
   }
 
   _showStartRequest() {
@@ -835,7 +856,7 @@
 
     // Cancel the debouncer so that error toasts from the error-manager will
     // not be overridden.
-    this.cancelDebouncer('draft-toast');
+    this.draftToastTask?.cancel();
     this._updateRequestToast(
       this._numPendingDraftRequests.number,
       /* requestFailed=*/ true
@@ -844,19 +865,13 @@
 
   _updateRequestToast(numPending: number, requestFailed?: boolean) {
     const message = this._getSavingMessage(numPending, requestFailed);
-    this.debounce(
-      'draft-toast',
+    this.draftToastTask = debounce(
+      this.draftToastTask,
       () => {
         // Note: the event is fired on the body rather than this element because
         // this element may not be attached by the time this executes, in which
         // case the event would not bubble.
-        document.body.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(document.body, message);
       },
       TOAST_DEBOUNCE_INTERVAL
     );
@@ -873,7 +888,7 @@
       throw new Error('undefined draft or changeNum or patchNum');
     }
     this._showStartRequest();
-    return this.$.restAPI
+    return this.restApiService
       .saveDiffDraft(this.changeNum, this.patchNum, draft)
       .then(result => {
         if (result.ok) {
@@ -898,7 +913,7 @@
     }
     this._showStartRequest();
     if (!draft.id) throw new Error('Missing id in comment draft.');
-    return this.$.restAPI
+    return this.restApiService
       .deleteDiffDraft(this.changeNum, this.patchNum, {id: draft.id})
       .then(result => {
         if (result.ok) {
@@ -912,7 +927,7 @@
 
   _getPatchNum(): PatchSetNum {
     const patchNum = this.isOnParent()
-      ? ('PARENT' as PatchSetNum)
+      ? ('PARENT' as BasePatchSetNum)
       : this.patchNum;
     if (patchNum === undefined) throw new Error('patchNum undefined');
     return patchNum;
@@ -931,21 +946,11 @@
 
     // Only apply local drafts to comments that haven't been saved
     // remotely, and haven't been given a default message already.
-    //
-    // Don't get local draft if there is another comment that is currently
-    // in an editing state.
-    if (
-      !comment ||
-      comment.id ||
-      comment.message ||
-      comment.__otherEditing ||
-      !comment.path
-    ) {
-      if (comment) delete comment.__otherEditing;
+    if (!comment || comment.id || comment.message || !comment.path) {
       return;
     }
 
-    const draft = this.$.storage.getDraftComment({
+    const draft = this.storage.getDraftComment({
       changeNum,
       patchNum: this._getPatchNum(),
       path: comment.path,
@@ -1024,7 +1029,7 @@
     ) {
       throw new Error('undefined comment or id or changeNum or patchNum');
     }
-    this.$.restAPI
+    this.restApiService
       .deleteComment(
         this.changeNum,
         this.patchNum,
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
index c021461..aad8c98 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
@@ -239,22 +239,43 @@
       --gr-account-label-text-style: {
         font-weight: var(--font-weight-bold);
       }
-      --account-max-length: 120px;
-      width: 120px;
+      --account-max-length: 130px;
+      width: 150px;
     }
     .draft gr-account-label {
       width: unset;
     }
+    .portedMessage {
+      margin: 0 var(--spacing-m);
+    }
   </style>
   <div id="container" class="container">
     <div class="header" id="header" on-click="_handleToggleCollapsed">
       <div class="headerLeft">
-        <gr-account-label
-          account="[[_getAuthor(comment, _selfAccount)]]"
-          class$="[[_computeAccountLabelClass(draft)]]"
-          hide-status=""
-        >
-        </gr-account-label>
+        <template is="dom-if" if="[[comment.robot_id]]">
+          <span class="robotName"> [[comment.robot_id]] </span>
+        </template>
+        <template is="dom-if" if="[[!comment.robot_id]]">
+          <gr-account-label
+            account="[[_getAuthor(comment, _selfAccount)]]"
+            class$="[[_computeAccountLabelClass(draft)]]"
+            hide-status=""
+          >
+          </gr-account-label>
+        </template>
+        <template is="dom-if" if="[[showPortedComment]]">
+          <a href="[[_getUrlForComment(comment)]]"
+            ><span class="portedMessage" on-click="_handlePortedMessageClick"
+              >From patchset [[comment.patch_set]]</span
+            ></a
+          >
+          <a
+            href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Porting+Comments"
+            target="_blank"
+          >
+            <iron-icon icon="gr-icons:bug" title="report a problem"></iron-icon>
+          </a>
+        </template>
         <gr-tooltip-content
           class="draftTooltip"
           has-tooltip=""
@@ -464,15 +485,11 @@
         on-confirm="_handleConfirmDiscard"
         on-cancel="_closeConfirmDiscardOverlay"
       >
-        <div class="header" slot="header">
-          Discard comment
-        </div>
+        <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>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
index 10925af..be647ab 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
@@ -19,7 +19,8 @@
 import './gr-comment.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {__testOnly_UNSAVED_MESSAGE} from './gr-comment.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
+import {SpecialFilePath, Side} from '../../../constants/constants.js';
+import {stubRestApi, stubStorage} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-comment');
 
@@ -39,16 +40,12 @@
     let openOverlaySpy;
 
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getAccount() {
-          return Promise.resolve({
-            email: 'dhruvsri@google.com',
-            name: 'Dhruv Srivastava',
-            _account_id: 1083225,
-            avatars: [{url: 'abc', height: 32}],
-          });
-        },
-      });
+      stubRestApi('getAccount').returns(Promise.resolve({
+        email: 'dhruvsri@google.com',
+        name: 'Dhruv Srivastava',
+        _account_id: 1083225,
+        avatars: [{url: 'abc', height: 32}],
+      }));
       element = basicFixture.instantiate();
       element.comment = {
         author: {
@@ -118,7 +115,7 @@
     });
 
     test('message is not retrieved from storage when other edits', done => {
-      const storageStub = sinon.stub(element.$.storage, 'getDraftComment');
+      const storageStub = stubStorage('getDraftComment');
       const loadSpy = sinon.spy(element, '_loadLocalDraft');
 
       element.changeNum = 1;
@@ -129,7 +126,6 @@
           email: 'tenn1sballchaser@aol.com',
         },
         line: 5,
-        __otherEditing: true,
       };
       flush(() => {
         assert.isTrue(loadSpy.called);
@@ -139,7 +135,7 @@
     });
 
     test('message is retrieved from storage when no other edits', done => {
-      const storageStub = sinon.stub(element.$.storage, 'getDraftComment');
+      const storageStub = stubStorage('getDraftComment');
       const loadSpy = sinon.spy(element, '_loadLocalDraft');
 
       element.changeNum = 1;
@@ -275,8 +271,7 @@
     });
 
     test('delete comment', done => {
-      sinon.stub(
-          element.$.restAPI, 'deleteComment').returns(Promise.resolve({}));
+      const stub = stubRestApi('deleteComment').returns(Promise.resolve({}));
       sinon.spy(element.confirmDeleteOverlay, 'open');
       element.changeNum = 42;
       element.patchNum = 0xDEADBEEF;
@@ -293,7 +288,7 @@
                   .querySelector('#confirmDeleteComment');
           dialog.message = 'removal reason';
           element._handleConfirmDeleteComment();
-          assert.isTrue(element.$.restAPI.deleteComment.calledWith(
+          assert.isTrue(stub.calledWith(
               42, 0xDEADBEEF, 'baf0414d_60047215', 'removal reason'));
           done();
         });
@@ -375,7 +370,7 @@
       element.patchNum = 1;
       const updateRequestStub = sinon.stub(element, '_updateRequestToast');
       const diffDraftStub =
-        sinon.stub(element.$.restAPI, 'saveDiffDraft').returns(
+        stubRestApi('saveDiffDraft').returns(
             Promise.resolve({ok: false}));
       element._saveDraft({id: 'abc_123'});
       flush(() => {
@@ -411,7 +406,7 @@
       element.patchNum = 1;
       const updateRequestStub = sinon.stub(element, '_updateRequestToast');
       const diffDraftStub =
-        sinon.stub(element.$.restAPI, 'saveDiffDraft').returns(
+        stubRestApi('saveDiffDraft').returns(
             Promise.reject(new Error()));
       element._saveDraft({id: 'abc_123'});
       flush(() => {
@@ -446,44 +441,35 @@
     let element;
 
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-        getConfig() { return Promise.resolve({}); },
-        saveDiffDraft() {
-          return Promise.resolve({
-            ok: true,
-            text() {
-              return Promise.resolve(
-                  ')]}\'\n{' +
-                  '"id": "baf0414d_40572e03",' +
-                  '"path": "/path/to/file",' +
-                  '"line": 5,' +
-                  '"updated": "2015-12-08 21:52:36.177000000",' +
-                  '"message": "saved!"' +
-                '}'
-              );
-            },
-          });
+      stubRestApi('getAccount').returns(Promise.resolve(null));
+      stubRestApi('saveDiffDraft').returns(Promise.resolve({
+        ok: true,
+        text() {
+          return Promise.resolve(
+              ')]}\'\n{' +
+              '"id": "baf0414d_40572e03",' +
+              '"path": "/path/to/file",' +
+              '"line": 5,' +
+              '"updated": "2015-12-08 21:52:36.177000000",' +
+              '"message": "saved!"' +
+              '}'
+          );
         },
-        removeChangeReviewer() {
-          return Promise.resolve({ok: true});
-        },
-      });
-      stub('gr-storage', {
-        getDraftComment() { return null; },
-      });
+      }));
+      stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
       element = draftFixture.instantiate();
+      sinon.stub(element.storage, 'getDraftComment').returns(null);
       element.changeNum = 42;
       element.patchNum = 1;
       element.editing = false;
       element.comment = {
-        __commentSide: 'right',
+        diffSide: Side.RIGHT,
         __draft: true,
         __draftID: 'temp_draft_id',
         path: '/path/to/file',
         line: 5,
       };
-      element.commentSide = 'right';
+      element.diffSide = Side.RIGHT;
     });
 
     test('button visibility states', () => {
@@ -695,9 +681,8 @@
         assert.isTrue(runDetailsLink.href.indexOf(element.comment.url) !== -1);
 
         const robotServiceName = element.shadowRoot
-            .querySelector('gr-account-label')
-            .shadowRoot.querySelector('span.name');
-        assert.equal(robotServiceName.textContent.trim(), 'Display name Robot');
+            .querySelector('.robotName');
+        assert.equal(robotServiceName.textContent.trim(), 'happy_robot_id');
 
         const authorName = element.shadowRoot
             .querySelector('.robotId');
@@ -733,13 +718,13 @@
         path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS, line: undefined,
         range: undefined};
       element.comment = comment;
-      flushAsynchronousOperations();
+      flush();
       MockInteractions.tap(element.shadowRoot
           .querySelector('.edit'));
       assert.isTrue(element.editing);
 
       element._messageText = 'hello world';
-      const eraseMessageDraftSpy = sinon.spy(element.$.storage,
+      const eraseMessageDraftSpy = sinon.spy(element.storage,
           'eraseDraftComment');
       const mockEvent = {preventDefault: sinon.stub()};
       element._handleSave(mockEvent);
@@ -783,7 +768,7 @@
       });
       MockInteractions.tap(element.shadowRoot
           .querySelector('.cancel'));
-      element.flushDebouncer('fire-update');
+      element.fireUpdateTask.flush();
       element._messageText = '';
       flush();
       MockInteractions.pressAndReleaseKeyOn(element.textarea, 27); // esc
@@ -804,7 +789,7 @@
     test('storage is cleared only after save success', () => {
       element._messageText = 'test';
       const eraseStub = sinon.stub(element, '_eraseDraftComment');
-      sinon.stub(element.$.restAPI, 'getResponseObject')
+      stubRestApi('getResponseObject')
           .returns(Promise.resolve({}));
 
       sinon.stub(element, '_saveDraft').returns(Promise.resolve({ok: false}));
@@ -882,21 +867,20 @@
 
     test('draft saving/editing', done => {
       const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-      const cancelDebounce = sinon.stub(element, 'cancelDebouncer');
 
       element.draft = true;
       flush();
       MockInteractions.tap(element.shadowRoot
           .querySelector('.edit'));
       element._messageText = 'good news, everyone!';
-      element.flushDebouncer('fire-update');
-      element.flushDebouncer('store');
+      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.flushDebouncer('fire-update');
-      element.flushDebouncer('store');
+      element.fireUpdateTask.flush();
+      element.storeTask.flush();
       assert.isTrue(dispatchEventStub.calledTwice);
 
       MockInteractions.tap(element.shadowRoot
@@ -907,11 +891,10 @@
 
       element._xhrPromise.then(draft => {
         assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-save');
-        assert(cancelDebounce.calledWith('store'));
+        assert.isFalse(element.storeTask.isActive());
 
         assert.deepEqual(dispatchEventStub.lastCall.args[0].detail, {
           comment: {
-            __commentSide: 'right',
             __draft: true,
             __draftID: 'temp_draft_id',
             id: 'baf0414d_40572e03',
@@ -956,8 +939,8 @@
       MockInteractions.tap(element.shadowRoot
           .querySelector('.edit'));
       element._messageText = 'good news, everyone!';
-      element.flushDebouncer('fire-update');
-      element.flushDebouncer('store');
+      element.fireUpdateTask.flush();
+      element.storeTask.flush();
 
       element.disabled = true;
       MockInteractions.tap(element.shadowRoot
@@ -1039,11 +1022,11 @@
 
     test('cancelling an unsaved draft discards, persists in storage', () => {
       const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = sinon.stub(element.$.storage, 'setDraftComment');
-      const eraseStub = sinon.stub(element.$.storage, 'eraseDraftComment');
+      const storeStub = stubStorage('setDraftComment');
+      const eraseStub = stubStorage('eraseDraftComment');
       element._messageText = 'test text';
       flush();
-      element.flushDebouncer('store');
+      element.storeTask.flush();
 
       assert.isTrue(storeStub.called);
       assert.equal(storeStub.lastCall.args[1], 'test text');
@@ -1055,10 +1038,10 @@
     test('cancelling edit on a saved draft does not store', () => {
       element.comment.id = 'foo';
       const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = sinon.stub(element.$.storage, 'setDraftComment');
+      const storeStub = stubStorage('setDraftComment');
       element._messageText = 'test text';
       flush();
-      element.flushDebouncer('store');
+      if (element.storeTask) element.storeTask.flush();
 
       assert.isFalse(storeStub.called);
       element._handleCancel({preventDefault: () => {}});
@@ -1139,7 +1122,7 @@
           updated: '2017-04-04 15:36:17.000000000',
           message: 'This is a robot comment with a fix.',
           unresolved: false,
-          __commentSide: 'right',
+          diffSide: Side.RIGHT,
           collapsed: false,
         },
         {
@@ -1149,7 +1132,7 @@
           path: 'Documentation/config-gerrit.txt',
           patchNum: 1,
           side: 'REVISION',
-          __commentSide: 'right',
+          diffSide: Side.RIGHT,
           line: 10,
           in_reply_to: 'eb0d03fd_5e95904f',
           message: '> This is a robot comment with a fix.\n\nPlease fix.',
@@ -1214,7 +1197,7 @@
           updated: '2017-04-04 15:36:17.000000000',
           message: 'This is a robot comment with a fix.',
           unresolved: false,
-          __commentSide: 'right',
+          diffSide: Side.RIGHT,
           collapsed: false,
         },
       ];
@@ -1243,9 +1226,7 @@
 
     let clock;
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-      });
+      stubRestApi('getAccount').returns(Promise.resolve(null));
       clock = sinon.useFakeTimers();
     });
 
@@ -1255,18 +1236,15 @@
     });
 
     test('show tip when no cached record', done => {
-      // fake stub for storage
-      const respectfulGetStub = sinon.stub();
-      const respectfulSetStub = sinon.stub();
-      stub('gr-storage', {
-        getRespectfulTipVisibility() { return respectfulGetStub(); },
-        setRespectfulTipVisibility() { return respectfulSetStub(); },
-      });
-      respectfulGetStub.returns(null);
       element = draftFixture.instantiate();
+      const respectfulGetStub =
+          sinon.stub(element.storage, 'getRespectfulTipVisibility');
+      const respectfulSetStub =
+          sinon.stub(element.storage, 'setRespectfulTipVisibility');
+      respectfulGetStub.returns(null);
       // fake random
       element.getRandomNum = () => 0;
-      element.comment = {__editing: true};
+      element.comment = {__editing: true, __draft: true};
       flush(() => {
         assert.isTrue(respectfulGetStub.called);
         assert.isTrue(respectfulSetStub.called);
@@ -1278,18 +1256,15 @@
     });
 
     test('add 14-day delays once dismissed', done => {
-      // fake stub for storage
-      const respectfulGetStub = sinon.stub();
-      const respectfulSetStub = sinon.stub();
-      stub('gr-storage', {
-        getRespectfulTipVisibility() { return respectfulGetStub(); },
-        setRespectfulTipVisibility(days) { return respectfulSetStub(days); },
-      });
-      respectfulGetStub.returns(null);
       element = draftFixture.instantiate();
+      const respectfulGetStub =
+          sinon.stub(element.storage, 'getRespectfulTipVisibility');
+      const respectfulSetStub =
+          sinon.stub(element.storage, 'setRespectfulTipVisibility');
+      respectfulGetStub.returns(null);
       // fake random
       element.getRandomNum = () => 0;
-      element.comment = {__editing: true};
+      element.comment = {__editing: true, __draft: true};
       flush(() => {
         assert.isTrue(respectfulGetStub.called);
         assert.isTrue(respectfulSetStub.called);
@@ -1307,18 +1282,15 @@
     });
 
     test('do not show tip when fall out of probability', done => {
-      // fake stub for storage
-      const respectfulGetStub = sinon.stub();
-      const respectfulSetStub = sinon.stub();
-      stub('gr-storage', {
-        getRespectfulTipVisibility() { return respectfulGetStub(); },
-        setRespectfulTipVisibility() { return respectfulSetStub(); },
-      });
-      respectfulGetStub.returns(null);
       element = draftFixture.instantiate();
+      const respectfulGetStub =
+          sinon.stub(element.storage, 'getRespectfulTipVisibility');
+      const respectfulSetStub =
+          sinon.stub(element.storage, 'setRespectfulTipVisibility');
+      respectfulGetStub.returns(null);
       // fake random
       element.getRandomNum = () => 3;
-      element.comment = {__editing: true};
+      element.comment = {__editing: true, __draft: true};
       flush(() => {
         assert.isTrue(respectfulGetStub.called);
         assert.isFalse(respectfulSetStub.called);
@@ -1330,15 +1302,12 @@
     });
 
     test('show tip when editing changed to true', done => {
-      // fake stub for storage
-      const respectfulGetStub = sinon.stub();
-      const respectfulSetStub = sinon.stub();
-      stub('gr-storage', {
-        getRespectfulTipVisibility() { return respectfulGetStub(); },
-        setRespectfulTipVisibility() { return respectfulSetStub(); },
-      });
-      respectfulGetStub.returns(null);
       element = draftFixture.instantiate();
+      const respectfulGetStub =
+          sinon.stub(element.storage, 'getRespectfulTipVisibility');
+      const respectfulSetStub =
+          sinon.stub(element.storage, 'setRespectfulTipVisibility');
+      respectfulGetStub.returns(null);
       // fake random
       element.getRandomNum = () => 0;
       element.comment = {__editing: false};
@@ -1362,18 +1331,15 @@
     });
 
     test('no tip when cached record', done => {
-      // fake stub for storage
-      const respectfulGetStub = sinon.stub();
-      const respectfulSetStub = sinon.stub();
-      stub('gr-storage', {
-        getRespectfulTipVisibility() { return respectfulGetStub(); },
-        setRespectfulTipVisibility() { return respectfulSetStub(); },
-      });
-      respectfulGetStub.returns({});
       element = draftFixture.instantiate();
+      const respectfulGetStub =
+          sinon.stub(element.storage, 'getRespectfulTipVisibility');
+      const respectfulSetStub =
+          sinon.stub(element.storage, 'setRespectfulTipVisibility');
+      respectfulGetStub.returns({});
       // fake random
       element.getRandomNum = () => 0;
-      element.comment = {__editing: true};
+      element.comment = {__editing: true, __draft: true};
       flush(() => {
         assert.isTrue(respectfulGetStub.called);
         assert.isFalse(respectfulSetStub.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-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 a636a07..0f071abb 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
@@ -16,8 +16,6 @@
  */
 import '../gr-dialog/gr-dialog';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-delete-comment-dialog_html';
 import {property, customElement} from '@polymer/decorators';
@@ -35,9 +33,7 @@
 }
 
 @customElement('gr-confirm-delete-comment-dialog')
-export class GrConfirmDeleteCommentDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrConfirmDeleteCommentDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
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 47dacc3..110c242 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
@@ -19,8 +19,6 @@
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-copy-clipboard_html';
 import {GrButton} from '../gr-button/gr-button';
@@ -39,11 +37,8 @@
   $: {button: GrButton; icon: IronIconElement; input: HTMLInputElement};
 }
 
-/** @extends PolymerElement */
 @customElement('gr-copy-clipboard')
-export class GrCopyClipboard extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCopyClipboard extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -87,7 +82,7 @@
       this.$.input.style.display = 'none';
     }
     this.$.icon.icon = 'gr-icons:check';
-    this.async(
+    setTimeout(
       () => (this.$.icon.icon = 'gr-icons:content-copy'),
       COPY_TIMEOUT_MS
     );
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
index 197c94c..3ccc46f 100644
--- 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
@@ -17,7 +17,7 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
-  <style include="shared-styles">
+  <style>
     .text {
       align-items: center;
       display: flex;
@@ -48,6 +48,12 @@
       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;
@@ -59,7 +65,7 @@
       class="copyText"
       type="text"
       bind-value="[[text]]"
-      on-tap="_handleInputClick"
+      on-click="_handleInputClick"
       readonly=""
     >
       <input
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts
deleted file mode 100644
index bbbce16..0000000
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-export const GrCountStringFormatter = {
-  /**
-   * Returns a count plus string that is pluralized when necessary.
-   */
-  computePluralString(count: number, noun: string): string {
-    return this.computeString(count, noun) + (count > 1 ? 's' : '');
-  },
-
-  /**
-   * Returns a count plus string that is not pluralized.
-   */
-  computeString(count: number, noun: string): string {
-    if (count === 0) {
-      return '';
-    }
-    return `${count} ${noun}`;
-  },
-
-  /**
-   * Returns a count plus arbitrary text.
-   */
-  computeShortString(count: number, text: string): string {
-    if (count === 0) {
-      return '';
-    }
-    return `${count}${text}`;
-  },
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.js b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.js
deleted file mode 100644
index 36637ec..0000000
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.js
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import {GrCountStringFormatter} from './gr-count-string-formatter.js';
-
-suite('gr-count-string-formatter tests', () => {
-  test('computeString', () => {
-    const noun = 'unresolved';
-    assert.equal(GrCountStringFormatter.computeString(0, noun), '');
-    assert.equal(GrCountStringFormatter.computeString(1, noun),
-        '1 unresolved');
-    assert.equal(GrCountStringFormatter.computeString(2, noun),
-        '2 unresolved');
-  });
-
-  test('computeShortString', () => {
-    const noun = 'c';
-    assert.equal(GrCountStringFormatter.computeShortString(0, noun), '');
-    assert.equal(GrCountStringFormatter.computeShortString(1, noun), '1c');
-    assert.equal(GrCountStringFormatter.computeShortString(2, noun), '2c');
-  });
-
-  test('computePluralString', () => {
-    const noun = 'comment';
-    assert.equal(GrCountStringFormatter.computePluralString(0, noun), '');
-    assert.equal(GrCountStringFormatter.computePluralString(1, noun),
-        '1 comment');
-    assert.equal(GrCountStringFormatter.computePluralString(2, noun),
-        '2 comments');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index 9fdbb34..8408c78 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
@@ -14,22 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-cursor-manager_html';
+import {BehaviorSubject} from 'rxjs';
 import {ScrollMode} from '../../../constants/constants';
-import {customElement, property, observe} from '@polymer/decorators';
-
-export interface GrCursorManager {
-  $: {};
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-cursor-manager': GrCursorManager;
-  }
-}
 
 /**
  * Return type for cursor moves, that indicate whether a move was possible.
@@ -54,40 +40,40 @@
 export type Stop = HTMLElement | AbortStop;
 
 /**
- * Type guard and checker to check if a stop can be targetted.
- * Abort stops cannot be targetted.
+ * Type guard and checker to check if a stop can be targeted.
+ * Abort stops cannot be targeted.
  */
 export function isTargetable(stop: Stop): stop is HTMLElement {
   return !(stop instanceof AbortStop);
 }
 
-@customElement('gr-cursor-manager')
-export class GrCursorManager extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
-  static get template() {
-    return htmlTemplate;
+export class GrCursorManager {
+  get target(): HTMLElement | null {
+    return this.targetSubject.getValue();
   }
 
-  @property({type: Object, notify: true})
-  target: HTMLElement | null = null;
+  set target(target: HTMLElement | null) {
+    this.targetSubject.next(target);
+    this._scrollToTarget();
+  }
+
+  private targetSubject = new BehaviorSubject<HTMLElement | null>(null);
+
+  target$ = this.targetSubject.asObservable();
 
   /**
    * The height of content intended to be included with the target.
    */
-  @property({type: Number})
   _targetHeight: number | null = null;
 
   /**
    * The index of the current target (if any). -1 otherwise.
    */
-  @property({type: Number, notify: true})
   index = -1;
 
   /**
    * The class to apply to the current target. Use null for no class.
    */
-  @property({type: String})
   cursorTargetClass: string | null = null;
 
   /**
@@ -98,29 +84,29 @@
    *
    * @type {string|undefined}
    */
-  @property({type: String})
   scrollMode: string = ScrollMode.NEVER;
 
   /**
    * When true, will call element.focus() during scrolling.
    */
-  @property({type: Boolean})
   focusOnMove = false;
 
-  @property({type: Array})
-  stops: Stop[] = [];
+  set stops(stops: Stop[]) {
+    this.stopsInternal = stops;
+    this._updateIndex();
+  }
+
+  get stops(): Stop[] {
+    return this.stopsInternal;
+  }
+
+  private stopsInternal: Stop[] = [];
 
   /** Only non-AbortStop stops. */
   get targetableStops(): HTMLElement[] {
     return this.stops.filter(isTargetable);
   }
 
-  /** @override */
-  detached() {
-    super.detached();
-    this.unsetCursor();
-  }
-
   /**
    * Move the cursor forward. Clipped to the ends of the stop list.
    *
@@ -261,14 +247,14 @@
     this._targetHeight = null;
   }
 
-  isAtStart() {
-    return this.index === 0;
+  /** Returns true if there are no stops, or we are on the first stop. */
+  isAtStart(): boolean {
+    return this.stops.length === 0 || this.index === 0;
   }
 
-  isAtEnd() {
-    // Unset cursor should not be considered "at end", even when there are no
-    // cursor stops.
-    return this.index !== -1 && this.index === this.stops.length - 1;
+  /** Returns true if there are no stops, or we are on the last stop. */
+  isAtEnd(): boolean {
+    return this.stops.length === 0 || this.index === this.stops.length - 1;
   }
 
   moveToStart() {
@@ -386,7 +372,6 @@
     }
   }
 
-  @observe('stops')
   _updateIndex() {
     if (!this.target) {
       this.index = -1;
@@ -419,9 +404,6 @@
     return top;
   }
 
-  /**
-   * @return
-   */
   _targetIsVisible(top: number) {
     const dims = this._getWindowDims();
     return (
@@ -436,7 +418,6 @@
     return top + -dims.innerHeight / 3 + target.offsetHeight / 2;
   }
 
-  @observe('target')
   _scrollToTarget() {
     if (!this.target || this.scrollMode === ScrollMode.NEVER) {
       return;
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.ts
deleted file mode 100644
index 1489006..0000000
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_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``;
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
index 33aeafc..e51d190 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,10 +18,9 @@
 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} from './gr-cursor-manager.js';
+import {AbortStop, CursorMoveResult, GrCursorManager} from './gr-cursor-manager.js';
 
 const basicTestFixutre = fixtureFromTemplate(html`
-    <gr-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
     <ul>
       <li>A</li>
       <li>B</li>
@@ -31,174 +30,180 @@
 `);
 
 suite('gr-cursor-manager tests', () => {
-  let element;
+  let cursor;
   let list;
 
   setup(() => {
-    const fixtureElements = basicTestFixutre.instantiate();
-    element = fixtureElements[0];
-    list = fixtureElements[1];
+    list = basicTestFixutre.instantiate();
+    cursor = new GrCursorManager();
+    cursor.cursorTargetClass = 'targeted';
   });
 
   test('core cursor functionality', () => {
     // The element is initialized into the proper state.
-    assert.isArray(element.stops);
-    assert.equal(element.stops.length, 0);
-    assert.equal(element.index, -1);
-    assert.isNotOk(element.target);
+    assert.isArray(cursor.stops);
+    assert.equal(cursor.stops.length, 0);
+    assert.equal(cursor.index, -1);
+    assert.isNotOk(cursor.target);
 
     // Initialize the cursor with its stops.
-    element.stops = [...list.querySelectorAll('li')];
+    cursor.stops = [...list.querySelectorAll('li')];
 
     // It should have the stops but it should not be targeting any of them.
-    assert.isNotNull(element.stops);
-    assert.equal(element.stops.length, 4);
-    assert.equal(element.index, -1);
-    assert.isNotOk(element.target);
+    assert.isNotNull(cursor.stops);
+    assert.equal(cursor.stops.length, 4);
+    assert.equal(cursor.index, -1);
+    assert.isNotOk(cursor.target);
 
     // Select the third stop.
-    element.setCursor(list.children[2]);
+    cursor.setCursor(list.children[2]);
 
     // It should update its internal state and update the element's class.
-    assert.equal(element.index, 2);
-    assert.equal(element.target, list.children[2]);
+    assert.equal(cursor.index, 2);
+    assert.equal(cursor.target, list.children[2]);
     assert.isTrue(list.children[2].classList.contains('targeted'));
-    assert.isFalse(element.isAtStart());
-    assert.isFalse(element.isAtEnd());
+    assert.isFalse(cursor.isAtStart());
+    assert.isFalse(cursor.isAtEnd());
 
     // Progress the cursor.
-    let result = element.next();
+    let result = cursor.next();
 
     // Confirm that the next stop is selected and that the previous stop is
     // unselected.
     assert.equal(result, CursorMoveResult.MOVED);
-    assert.equal(element.index, 3);
-    assert.equal(element.target, list.children[3]);
-    assert.isTrue(element.isAtEnd());
+    assert.equal(cursor.index, 3);
+    assert.equal(cursor.target, list.children[3]);
+    assert.isTrue(cursor.isAtEnd());
     assert.isFalse(list.children[2].classList.contains('targeted'));
     assert.isTrue(list.children[3].classList.contains('targeted'));
 
     // Progress the cursor.
-    result = element.next();
+    result = cursor.next();
 
     // We should still be at the end.
     assert.equal(result, CursorMoveResult.CLIPPED);
-    assert.equal(element.index, 3);
-    assert.equal(element.target, list.children[3]);
-    assert.isTrue(element.isAtEnd());
+    assert.equal(cursor.index, 3);
+    assert.equal(cursor.target, list.children[3]);
+    assert.isTrue(cursor.isAtEnd());
 
     // Wind the cursor all the way back to the first stop.
-    result = element.previous();
+    result = cursor.previous();
     assert.equal(result, CursorMoveResult.MOVED);
-    result = element.previous();
+    result = cursor.previous();
     assert.equal(result, CursorMoveResult.MOVED);
-    result = element.previous();
+    result = cursor.previous();
     assert.equal(result, CursorMoveResult.MOVED);
 
     // The element state should reflect the start of the list.
-    assert.equal(element.index, 0);
-    assert.equal(element.target, list.children[0]);
-    assert.isTrue(element.isAtStart());
+    assert.equal(cursor.index, 0);
+    assert.equal(cursor.target, list.children[0]);
+    assert.isTrue(cursor.isAtStart());
     assert.isTrue(list.children[0].classList.contains('targeted'));
 
     const newLi = document.createElement('li');
     newLi.textContent = 'Z';
     list.insertBefore(newLi, list.children[0]);
-    element.stops = [...list.querySelectorAll('li')];
+    cursor.stops = [...list.querySelectorAll('li')];
 
-    assert.equal(element.index, 1);
+    assert.equal(cursor.index, 1);
 
     // De-select all targets.
-    element.unsetCursor();
+    cursor.unsetCursor();
 
     // There should now be no cursor target.
     assert.isFalse(list.children[1].classList.contains('targeted'));
-    assert.isNotOk(element.target);
-    assert.equal(element.index, -1);
+    assert.isNotOk(cursor.target);
+    assert.equal(cursor.index, -1);
+  });
+
+  test('isAtStart() returns true when there are no stops', () => {
+    cursor.stops = [];
+    assert.isTrue(cursor.isAtStart());
+  });
+
+  test('isAtEnd() returns true when there are no stops', () => {
+    cursor.stops = [];
+    assert.isTrue(cursor.isAtEnd());
   });
 
   test('next() goes to first element when no cursor is set', () => {
-    element.stops = [...list.querySelectorAll('li')];
-    const result = element.next();
+    cursor.stops = [...list.querySelectorAll('li')];
+    const result = cursor.next();
 
     assert.equal(result, CursorMoveResult.MOVED);
-    assert.equal(element.index, 0);
-    assert.equal(element.target, list.children[0]);
+    assert.equal(cursor.index, 0);
+    assert.equal(cursor.target, list.children[0]);
     assert.isTrue(list.children[0].classList.contains('targeted'));
-    assert.isTrue(element.isAtStart());
-    assert.isFalse(element.isAtEnd());
+    assert.isTrue(cursor.isAtStart());
+    assert.isFalse(cursor.isAtEnd());
   });
 
   test('next() resets the cursor when there are no stops', () => {
-    element.stops = [];
-    const result = element.next();
+    cursor.stops = [];
+    const result = cursor.next();
 
     assert.equal(result, CursorMoveResult.NO_STOPS);
-    assert.equal(element.index, -1);
-    assert.isNotOk(element.target);
+    assert.equal(cursor.index, -1);
+    assert.isNotOk(cursor.target);
     assert.isFalse(list.children[1].classList.contains('targeted'));
-    assert.isFalse(element.isAtStart());
-    assert.isFalse(element.isAtEnd());
   });
 
   test('previous() goes to last element when no cursor is set', () => {
-    element.stops = [...list.querySelectorAll('li')];
-    const result = element.previous();
+    cursor.stops = [...list.querySelectorAll('li')];
+    const result = cursor.previous();
 
     assert.equal(result, CursorMoveResult.MOVED);
     const lastIndex = list.children.length - 1;
-    assert.equal(element.index, lastIndex);
-    assert.equal(element.target, list.children[lastIndex]);
+    assert.equal(cursor.index, lastIndex);
+    assert.equal(cursor.target, list.children[lastIndex]);
     assert.isTrue(list.children[lastIndex].classList.contains('targeted'));
-    assert.isFalse(element.isAtStart());
-    assert.isTrue(element.isAtEnd());
+    assert.isFalse(cursor.isAtStart());
+    assert.isTrue(cursor.isAtEnd());
   });
 
   test('previous() resets the cursor when there are no stops', () => {
-    element.stops = [];
-    const result = element.previous();
+    cursor.stops = [];
+    const result = cursor.previous();
 
     assert.equal(result, CursorMoveResult.NO_STOPS);
-    assert.equal(element.index, -1);
-    assert.isNotOk(element.target);
+    assert.equal(cursor.index, -1);
+    assert.isNotOk(cursor.target);
     assert.isFalse(list.children[1].classList.contains('targeted'));
-    assert.isFalse(element.isAtStart());
-    assert.isFalse(element.isAtEnd());
   });
 
   test('_moveCursor', () => {
     // Initialize the cursor with its stops.
-    element.stops = [...list.querySelectorAll('li')];
+    cursor.stops = [...list.querySelectorAll('li')];
     // Select the first stop.
-    element.setCursor(list.children[0]);
+    cursor.setCursor(list.children[0]);
     const getTargetHeight = sinon.stub();
 
     // Move the cursor without an optional get target height function.
-    element._moveCursor(1);
+    cursor._moveCursor(1);
     assert.isFalse(getTargetHeight.called);
 
     // Move the cursor with an optional get target height function.
-    element._moveCursor(1, {getTargetHeight});
+    cursor._moveCursor(1, {getTargetHeight});
     assert.isTrue(getTargetHeight.called);
   });
 
   test('_moveCursor from for invalid index does not check height', () => {
-    element.stops = [];
+    cursor.stops = [];
     const getTargetHeight = sinon.stub();
-    element._moveCursor(1, () => false, {getTargetHeight});
+    cursor._moveCursor(1, () => false, {getTargetHeight});
     assert.isFalse(getTargetHeight.called);
   });
 
   test('setCursorAtIndex with noScroll', () => {
-    sinon.stub(element, '_targetIsVisible').callsFake(() => false);
+    sinon.stub(cursor, '_targetIsVisible').callsFake(() => false);
     const scrollStub = sinon.stub(window, 'scrollTo');
-    element.stops = [...list.querySelectorAll('li')];
-    element.scrollMode = 'keep-visible';
+    cursor.stops = [...list.querySelectorAll('li')];
+    cursor.scrollMode = 'keep-visible';
 
-    element.setCursorAtIndex(1, true);
+    cursor.setCursorAtIndex(1, true);
     assert.isFalse(scrollStub.called);
 
-    element.setCursorAtIndex(2);
+    cursor.setCursorAtIndex(2);
     assert.isTrue(scrollStub.called);
   });
 
@@ -206,30 +211,30 @@
     const isLetterB = function(row) {
       return row.textContent === 'B';
     };
-    element.stops = [...list.querySelectorAll('li')];
+    cursor.stops = [...list.querySelectorAll('li')];
     // Start cursor at the first stop.
-    element.setCursor(list.children[0]);
+    cursor.setCursor(list.children[0]);
 
     // Move forward to meet the next condition.
-    element.next({filter: isLetterB});
-    assert.equal(element.index, 1);
+    cursor.next({filter: isLetterB});
+    assert.equal(cursor.index, 1);
 
     // Nothing else meets the condition, should be at last stop.
-    element.next({filter: isLetterB});
-    assert.equal(element.index, 3);
+    cursor.next({filter: isLetterB});
+    assert.equal(cursor.index, 3);
 
     // Should stay at last stop if try to proceed.
-    element.next({filter: isLetterB});
-    assert.equal(element.index, 3);
+    cursor.next({filter: isLetterB});
+    assert.equal(cursor.index, 3);
 
     // Go back to the previous condition met. Should be back at.
     // stop 1.
-    element.previous({filter: isLetterB});
-    assert.equal(element.index, 1);
+    cursor.previous({filter: isLetterB});
+    assert.equal(cursor.index, 1);
 
     // Go back. No more meet the condition. Should be at stop 0.
-    element.previous({filter: isLetterB});
-    assert.equal(element.index, 0);
+    cursor.previous({filter: isLetterB});
+    assert.equal(cursor.index, 0);
   });
 
   test('focusOnMove prop', () => {
@@ -237,129 +242,129 @@
     for (let i = 0; i < listEls.length; i++) {
       sinon.spy(listEls[i], 'focus');
     }
-    element.stops = listEls;
-    element.setCursor(list.children[0]);
+    cursor.stops = listEls;
+    cursor.setCursor(list.children[0]);
 
-    element.focusOnMove = false;
-    element.next();
-    assert.isFalse(element.target.focus.called);
+    cursor.focusOnMove = false;
+    cursor.next();
+    assert.isFalse(cursor.target.focus.called);
 
-    element.focusOnMove = true;
-    element.next();
-    assert.isTrue(element.target.focus.called);
+    cursor.focusOnMove = true;
+    cursor.next();
+    assert.isTrue(cursor.target.focus.called);
   });
 
   suite('_scrollToTarget', () => {
     let scrollStub;
     setup(() => {
-      element.stops = [...list.querySelectorAll('li')];
-      element.scrollMode = 'keep-visible';
+      cursor.stops = [...list.querySelectorAll('li')];
+      cursor.scrollMode = 'keep-visible';
 
       // There is a target which has a targetNext
-      element.setCursor(list.children[0]);
-      element._moveCursor(1);
+      cursor.setCursor(list.children[0]);
+      cursor._moveCursor(1);
       scrollStub = sinon.stub(window, 'scrollTo');
       window.innerHeight = 60;
     });
 
     test('Called when top and bottom not visible', () => {
-      sinon.stub(element, '_targetIsVisible').returns(false);
-      element._scrollToTarget();
+      sinon.stub(cursor, '_targetIsVisible').returns(false);
+      cursor._scrollToTarget();
       assert.isTrue(scrollStub.called);
     });
 
     test('Not called when top and bottom visible', () => {
-      sinon.stub(element, '_targetIsVisible').returns(true);
-      element._scrollToTarget();
+      sinon.stub(cursor, '_targetIsVisible').returns(true);
+      cursor._scrollToTarget();
       assert.isFalse(scrollStub.called);
     });
 
     test('Called when top is visible, bottom is not, scroll is lower', () => {
-      const visibleStub = sinon.stub(element, '_targetIsVisible').callsFake(
+      const visibleStub = sinon.stub(cursor, '_targetIsVisible').callsFake(
           () => visibleStub.callCount === 2);
-      sinon.stub(element, '_getWindowDims').returns({
+      sinon.stub(cursor, '_getWindowDims').returns({
         scrollX: 123,
         scrollY: 15,
         innerHeight: 1000,
         pageYOffset: 0,
       });
-      sinon.stub(element, '_calculateScrollToValue').returns(20);
-      element._scrollToTarget();
+      sinon.stub(cursor, '_calculateScrollToValue').returns(20);
+      cursor._scrollToTarget();
       assert.isTrue(scrollStub.called);
       assert.isTrue(scrollStub.calledWithExactly(123, 20));
       assert.equal(visibleStub.callCount, 2);
     });
 
     test('Called when top is visible, bottom not, scroll is higher', () => {
-      const visibleStub = sinon.stub(element, '_targetIsVisible').callsFake(
+      const visibleStub = sinon.stub(cursor, '_targetIsVisible').callsFake(
           () => visibleStub.callCount === 2);
-      sinon.stub(element, '_getWindowDims').returns({
+      sinon.stub(cursor, '_getWindowDims').returns({
         scrollX: 123,
         scrollY: 25,
         innerHeight: 1000,
         pageYOffset: 0,
       });
-      sinon.stub(element, '_calculateScrollToValue').returns(20);
-      element._scrollToTarget();
+      sinon.stub(cursor, '_calculateScrollToValue').returns(20);
+      cursor._scrollToTarget();
       assert.isFalse(scrollStub.called);
       assert.equal(visibleStub.callCount, 2);
     });
 
     test('_calculateScrollToValue', () => {
-      sinon.stub(element, '_getWindowDims').returns({
+      sinon.stub(cursor, '_getWindowDims').returns({
         scrollX: 123,
         scrollY: 25,
         innerHeight: 300,
         pageYOffset: 0,
       });
-      assert.equal(element._calculateScrollToValue(1000, {offsetHeight: 10}),
+      assert.equal(cursor._calculateScrollToValue(1000, {offsetHeight: 10}),
           905);
     });
   });
 
   suite('AbortStops', () => {
     test('next() does not skip AbortStops', () => {
-      element.stops = [
+      cursor.stops = [
         document.createElement('li'),
         new AbortStop(),
         document.createElement('li'),
       ];
-      element.setCursorAtIndex(0);
+      cursor.setCursorAtIndex(0);
 
-      const result = element.next();
+      const result = cursor.next();
 
       assert.equal(result, CursorMoveResult.ABORTED);
-      assert.equal(element.index, 0);
+      assert.equal(cursor.index, 0);
     });
 
     test('setCursorAtIndex() does not target AbortStops', () => {
-      element.stops = [
+      cursor.stops = [
         document.createElement('li'),
         new AbortStop(),
         document.createElement('li'),
       ];
-      element.setCursorAtIndex(1);
-      assert.equal(element.index, -1);
+      cursor.setCursorAtIndex(1);
+      assert.equal(cursor.index, -1);
     });
 
     test('moveToStart() does not target AbortStop', () => {
-      element.stops = [
+      cursor.stops = [
         new AbortStop(),
         document.createElement('li'),
         document.createElement('li'),
       ];
-      element.moveToStart();
-      assert.equal(element.index, -1);
+      cursor.moveToStart();
+      assert.equal(cursor.index, -1);
     });
 
     test('moveToEnd() does not target AbortStop', () => {
-      element.stops = [
+      cursor.stops = [
         document.createElement('li'),
         document.createElement('li'),
         new AbortStop(),
       ];
-      element.moveToEnd();
-      assert.equal(element.index, -1);
+      cursor.moveToEnd();
+      assert.equal(cursor.index, -1);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
index c64dc2a..489b489 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,10 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-date-formatter_html';
 import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
@@ -30,11 +27,12 @@
   isWithinHalfYear,
   formatDate,
   utcOffsetString,
+  wasYesterday,
 } from '../../../utils/date-util';
 import {TimeFormat, DateFormat} from '../../../constants/constants';
 import {assertNever} from '../../../utils/common-util';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {Timestamp} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
 
 const TimeFormats = {
   TIME_12: 'h:mm A', // 2:14 PM
@@ -77,16 +75,8 @@
   }
 }
 
-export interface GrDateFormatter {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 @customElement('gr-date-formatter')
-export class GrDateFormatter extends TooltipMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrDateFormatter extends TooltipMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -104,6 +94,9 @@
   @property({type: Boolean})
   hasTooltip = false;
 
+  @property({type: Boolean})
+  showYesterday = false;
+
   /**
    * The title to be used as the native tooltip or by the tooltip behavior.
    */
@@ -130,13 +123,15 @@
   @property({type: Boolean})
   relativeOptionNoAgo = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadPreferences();
   }
 
@@ -210,11 +205,11 @@
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   _getPreferences() {
-    return this.$.restAPI.getPreferences();
+    return this.restApiService.getPreferences();
   }
 
   _computeDateStr(
@@ -222,7 +217,8 @@
     timeFormat?: string,
     dateFormat?: DateFormatPair,
     relative?: boolean,
-    showDateAndTime?: boolean
+    showDateAndTime?: boolean,
+    showYesterday?: boolean
   ) {
     if (!dateStr || !timeFormat || !dateFormat) {
       return '';
@@ -238,6 +234,8 @@
     let format = dateFormat.full;
     if (isWithinDay(now, date)) {
       format = timeFormat;
+    } else if (showYesterday && wasYesterday(now, date)) {
+      return `Yesterday at ${formatDate(date, timeFormat)}`;
     } else {
       if (isWithinHalfYear(now, date)) {
         format = dateFormat.short;
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts
index a5dd6d0..4808832 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts
@@ -17,7 +17,7 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
-  <style include="shared-styles">
+  <style>
     :host {
       color: inherit;
       display: inline;
@@ -25,7 +25,6 @@
   </style>
   <span>
     [[_computeDateStr(dateStr, _timeFormat, _dateFormat, _relative,
-    showDateAndTime)]]
+    showDateAndTime, showYesterday)]]
   </span>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
index 0a7f6dd..9a96c2d 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
@@ -19,6 +19,7 @@
 import './gr-date-formatter.js';
 import {parseDate} from '../../../utils/date-util.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromTemplate(html`
 <gr-date-formatter date-str="2015-09-24 23:30:17.033000000"></gr-date-formatter>
@@ -62,10 +63,8 @@
   function stubRestAPI(preferences) {
     const loggedInPromise = Promise.resolve(preferences !== null);
     const preferencesPromise = Promise.resolve(preferences);
-    stub('gr-rest-api-interface', {
-      getLoggedIn: sinon.stub().returns(loggedInPromise),
-      getPreferences: sinon.stub().returns(preferencesPromise),
-    });
+    stubRestApi('getLoggedIn').returns(loggedInPromise);
+    stubRestApi('getPreferences').returns(preferencesPromise);
     return Promise.all([loggedInPromise, preferencesPromise]);
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
index fa6403a..7022a39 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -16,8 +16,6 @@
  */
 import '../gr-button/gr-button';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-dialog_html';
 import {customElement, property, observe} from '@polymer/decorators';
@@ -36,9 +34,7 @@
 }
 
 @customElement('gr-dialog')
-export class GrDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
similarity index 74%
rename from polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.js
rename to polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
index 1238168..e7b7130 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
@@ -15,14 +15,15 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-dialog.js';
-import {isHidden} from '../../../test/test-utils.js';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import '../../../test/common-test-setup-karma';
+import {GrDialog} from './gr-dialog';
+import {isHidden, queryAndAssert} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-dialog');
 
 suite('gr-dialog tests', () => {
-  let element;
+  let element: GrDialog;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -34,12 +35,10 @@
     element.addEventListener('confirm', confirm);
     element.addEventListener('cancel', cancel);
 
-    MockInteractions.tap(
-        element.shadowRoot.querySelector('gr-button[primary]'));
+    MockInteractions.tap(queryAndAssert(element, 'gr-button[primary]'));
     assert.equal(confirm.callCount, 1);
 
-    MockInteractions.tap(
-        element.shadowRoot.querySelector('gr-button:not([primary])'));
+    MockInteractions.tap(queryAndAssert(element, 'gr-button:not([primary])'));
     assert.equal(cancel.callCount, 1);
   });
 
@@ -48,7 +47,11 @@
     const handleConfirmStub = sinon.stub(element, '_handleConfirm');
     const handleKeydownSpy = sinon.spy(element, '_handleKeydown');
     MockInteractions.pressAndReleaseKeyOn(
-        element.shadowRoot.querySelector('main'), 13, null, 'enter');
+      queryAndAssert(element, 'main'),
+      13,
+      null,
+      'enter'
+    );
     flush();
 
     assert.isTrue(handleKeydownSpy.called);
@@ -56,7 +59,11 @@
 
     element.confirmOnEnter = true;
     MockInteractions.pressAndReleaseKeyOn(
-        element.shadowRoot.querySelector('main'), 13, null, 'enter');
+      queryAndAssert(element, 'main'),
+      13,
+      null,
+      'enter'
+    );
     flush();
 
     assert.isTrue(handleConfirmStub.called);
@@ -81,11 +88,11 @@
   });
 
   test('empty cancel label hides cancel btn', () => {
-    assert.isFalse(isHidden(element.$.cancel));
+    const cancelButton = queryAndAssert(element, '#cancel');
+    assert.isFalse(isHidden(cancelButton));
     element.cancelLabel = '';
     flush();
 
-    assert.isTrue(isHidden(element.$.cancel));
+    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 02d039a..70a7bf3 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -17,20 +17,16 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-preferences_html';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {DiffPreferencesInfo} from '../../../types/common';
+import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrSelect} from '../gr-select/gr-select';
+import {appContext} from '../../../services/app-context';
 
 export interface GrDiffPreferences {
   $: {
-    restAPI: RestApiService & Element;
     lineWrappingInput: HTMLInputElement;
     showTabsInput: HTMLInputElement;
     showTrailingWhitespaceInput: HTMLInputElement;
@@ -42,9 +38,7 @@
 }
 
 @customElement('gr-diff-preferences')
-export class GrDiffPreferences extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDiffPreferences extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -55,8 +49,10 @@
   @property({type: Object})
   diffPrefs?: DiffPreferencesInfo;
 
+  private readonly restApiService = appContext.restApiService;
+
   loadData() {
-    return this.$.restAPI.getDiffPreferences().then(prefs => {
+    return this.restApiService.getDiffPreferences().then(prefs => {
       this.diffPrefs = prefs;
     });
   }
@@ -99,7 +95,7 @@
   save() {
     if (!this.diffPrefs)
       return Promise.reject(new Error('Missing diff preferences'));
-    return this.$.restAPI.saveDiffPreferences(this.diffPrefs).then(_ => {
+    return this.restApiService.saveDiffPreferences(this.diffPrefs).then(_ => {
       this.hasUnsavedChanges = false;
     });
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
index 54022ba..ed3d695 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
@@ -25,10 +25,11 @@
   </style>
   <div id="diffPreferences" class="gr-form-styles">
     <section>
-      <span class="title">Context</span>
+      <label for="contextLineSelect" class="title">Context</label>
       <span class="value">
         <gr-select id="contextSelect" bind-value="{{diffPrefs.context}}">
           <select
+            id="contextLineSelect"
             on-keypress="_handleDiffPrefsChanged"
             on-change="_handleDiffPrefsChanged"
           >
@@ -44,7 +45,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Fit to screen</span>
+      <label for="lineWrappingInput" class="title">Fit to screen</label>
       <span class="value">
         <input
           id="lineWrappingInput"
@@ -55,7 +56,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Diff width</span>
+      <label for="columnsInput" class="title">Diff width</label>
       <span class="value">
         <iron-input
           type="number"
@@ -79,7 +80,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Tab width</span>
+      <label for="tabSizeInput" class="title">Tab width</label>
       <span class="value">
         <iron-input
           type="number"
@@ -103,7 +104,7 @@
       </span>
     </section>
     <section hidden$="[[!diffPrefs.font_size]]">
-      <span class="title">Font size</span>
+      <label for="fontSizeInput" class="title">Font size</label>
       <span class="value">
         <iron-input
           type="number"
@@ -127,7 +128,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Show tabs</span>
+      <label for="showTabsInput" class="title">Show tabs</label>
       <span class="value">
         <input
           id="showTabsInput"
@@ -138,7 +139,9 @@
       </span>
     </section>
     <section>
-      <span class="title">Show trailing whitespace</span>
+      <label for="showTrailingWhitespaceInput" class="title"
+        >Show trailing whitespace</label
+      >
       <span class="value">
         <input
           id="showTrailingWhitespaceInput"
@@ -149,7 +152,9 @@
       </span>
     </section>
     <section>
-      <span class="title">Syntax highlighting</span>
+      <label for="syntaxHighlightInput" class="title"
+        >Syntax highlighting</label
+      >
       <span class="value">
         <input
           id="syntaxHighlightInput"
@@ -160,7 +165,9 @@
       </span>
     </section>
     <section>
-      <span class="title">Automatically mark viewed files reviewed</span>
+      <label for="automaticReviewInput" class="title"
+        >Automatically mark viewed files reviewed</label
+      >
       <span class="value">
         <input
           id="automaticReviewInput"
@@ -172,18 +179,19 @@
     </section>
     <section>
       <div class="pref">
-        <span class="title">Ignore Whitespace</span>
+        <label for="ignoreWhiteSpace" class="title">Ignore Whitespace</label>
         <span class="value">
           <gr-select bind-value="{{diffPrefs.ignore_whitespace}}">
             <select
+              id="ignoreWhiteSpace"
               on-keypress="_handleDiffPrefsChanged"
               on-change="_handleDiffPrefsChanged"
             >
               <option value="IGNORE_NONE">None</option>
               <option value="IGNORE_TRAILING">Trailing</option>
-              <option value="IGNORE_LEADING_AND_TRAILING"
-                >Leading &amp; trailing</option
-              >
+              <option value="IGNORE_LEADING_AND_TRAILING">
+                Leading &amp; trailing
+              </option>
               <option value="IGNORE_ALL">All</option>
             </select>
           </gr-select>
@@ -191,5 +199,4 @@
       </div>
     </section>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js
index ced8c6c..716ef2f 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-diff-preferences.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-diff-preferences');
 
@@ -50,11 +51,7 @@
       ignore_whitespace: 'IGNORE_NONE',
     };
 
-    stub('gr-rest-api-interface', {
-      getDiffPreferences() {
-        return Promise.resolve(diffPreferences);
-      },
-    });
+    stubRestApi('getDiffPreferences').returns(Promise.resolve(diffPreferences));
 
     element = basicFixture.instantiate();
 
@@ -89,7 +86,7 @@
   });
 
   test('save changes', () => {
-    sinon.stub(element.$.restAPI, 'saveDiffPreferences')
+    stubRestApi('saveDiffPreferences')
         .returns(Promise.resolve());
     const showTrailingWhitespaceCheckbox =
         valueOf('Show trailing whitespace', 'diffPreferences')
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 97747a0..19cbb22 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -16,15 +16,12 @@
  */
 import '@polymer/paper-tabs/paper-tabs';
 import '../gr-shell-command/gr-shell-command';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-download-commands_html';
 import {customElement, property, observe} from '@polymer/decorators';
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -35,7 +32,6 @@
 export interface GrDownloadCommands {
   $: {
     downloadTabs: PaperTabsElement;
-    restAPI: RestApiService & Element;
   };
 }
 
@@ -45,16 +41,14 @@
 }
 
 @customElement('gr-download-commands')
-export class GrDownloadCommands extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDownloadCommands extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
 
   // TODO(TS): maybe default to [] as only used in dom-repeat
   @property({type: Array})
-  comamnds?: Command[];
+  commands?: Command[];
 
   @property({type: Boolean})
   _loggedIn = false;
@@ -65,9 +59,11 @@
   @property({type: String, notify: true})
   selectedScheme?: string;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
@@ -79,7 +75,7 @@
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   @observe('_loggedIn')
@@ -87,7 +83,7 @@
     if (!loggedIn) {
       return;
     }
-    return this.$.restAPI.getPreferences().then(prefs => {
+    return this.restApiService.getPreferences().then(prefs => {
       if (prefs?.download_scheme) {
         // Note (issue 5180): normalize the download scheme with lower-case.
         this.selectedScheme = prefs.download_scheme.toLowerCase();
@@ -100,7 +96,9 @@
     if (scheme && scheme !== this.selectedScheme) {
       this.set('selectedScheme', scheme);
       if (this._loggedIn) {
-        this.$.restAPI.savePreferences({download_scheme: this.selectedScheme});
+        this.restApiService.savePreferences({
+          download_scheme: this.selectedScheme,
+        });
       }
     }
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
index ac90967..63e1cba 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
@@ -71,5 +71,4 @@
       ></gr-shell-command>
     </template>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
index 5429506..6427c6b 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
@@ -17,7 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-download-commands.js';
-import {isHidden} from '../../../test/test-utils.js';
+import {isHidden, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-download-commands');
 
@@ -50,6 +50,7 @@
 
   suite('unauthenticated', () => {
     setup(async () => {
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       element = basicFixture.instantiate();
       element.schemes = SCHEMES;
       element.commands = COMMANDS;
@@ -66,16 +67,12 @@
     });
 
     test('element visibility', () => {
-      assert.isFalse(isHidden(element.shadowRoot
-          .querySelector('paper-tabs')));
-      assert.isFalse(isHidden(element.shadowRoot
-          .querySelector('.commands')));
+      assert.isFalse(isHidden(element.shadowRoot.querySelector('paper-tabs')));
+      assert.isFalse(isHidden(element.shadowRoot.querySelector('.commands')));
 
       element.schemes = [];
-      assert.isTrue(isHidden(element.shadowRoot
-          .querySelector('paper-tabs')));
-      assert.isTrue(isHidden(element.shadowRoot
-          .querySelector('.commands')));
+      assert.isTrue(isHidden(element.shadowRoot.querySelector('paper-tabs')));
+      assert.isTrue(isHidden(element.shadowRoot.querySelector('.commands')));
     });
 
     test('tab selection', () => {
@@ -87,35 +84,30 @@
       assert.equal(element.$.downloadTabs.selected, '2');
     });
 
-    test('loads scheme from preferences', () => {
-      stub('gr-rest-api-interface', {
-        getPreferences() {
-          return Promise.resolve({download_scheme: 'repo'});
-        },
-      });
+    test('loads scheme from preferences', async () => {
+      const stub = stubRestApi('getPreferences').returns(
+          Promise.resolve({download_scheme: 'repo'}));
       element._loggedIn = true;
-      assert.isTrue(element.$.restAPI.getPreferences.called);
-      return element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
-        assert.equal(element.selectedScheme, 'repo');
-      });
+      await flush();
+      assert.isTrue(stub.called);
+      await stub.lastCall.returnValue;
+      assert.equal(element.selectedScheme, 'repo');
     });
 
-    test('normalize scheme from preferences', () => {
-      stub('gr-rest-api-interface', {
-        getPreferences() {
-          return Promise.resolve({download_scheme: 'REPO'});
-        },
-      });
+    test('normalize scheme from preferences', async () => {
+      const stub = stubRestApi('getPreferences').returns(
+          Promise.resolve({download_scheme: 'REPO'}));
       element._loggedIn = true;
-      return element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
-        assert.equal(element.selectedScheme, 'repo');
-      });
+      await flush();
+      assert.isTrue(stub.called);
+      await stub.lastCall.returnValue;
+      assert.equal(element.selectedScheme, 'repo');
     });
 
     test('saves scheme to preferences', () => {
       element._loggedIn = true;
-      const savePrefsStub = sinon.stub(element.$.restAPI, 'savePreferences')
-          .callsFake(() => Promise.resolve());
+      const savePrefsStub = stubRestApi('savePreferences').returns(
+          Promise.resolve());
 
       flush();
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index 2ea72ca..ef46cec 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -21,22 +21,16 @@
 import '../gr-button/gr-button';
 import '../gr-date-formatter/gr-date-formatter';
 import '../gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import '../gr-file-status-chip/gr-file-status-chip';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-dropdown-list_html';
 import {customElement, property, observe} from '@polymer/decorators';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
 import {Timestamp} from '../../../types/common';
+import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
 
 /**
- * fired when the selected value of the dropdown changes
- *
- * @event {change}
- */
-
-/**
- * Requred values are text and value. mobileText and triggerText will
+ * Required values are text and value. mobileText and triggerText will
  * fall back to text if not provided.
  *
  * If bottomText is not provided, nothing will display on the second
@@ -52,6 +46,7 @@
   mobileText?: string;
   date?: Timestamp;
   disabled?: boolean;
+  file?: NormalizedFileInfo;
 }
 
 export interface GrDropdownList {
@@ -67,9 +62,7 @@
 export type DropDownValueChangeEvent = CustomEvent<ValueChangeDetail>;
 
 @customElement('gr-dropdown-list')
-export class GrDropdownList extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrDropdownList extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -106,7 +99,7 @@
   _handleDropdownClick() {
     // async is needed so that that the click event is fired before the
     // dropdown closes (This was a bug for touch devices).
-    this.async(() => {
+    setTimeout(() => {
       this.$.dropdown.close();
     }, 1);
   }
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 28eb6a4..c163924 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
@@ -157,6 +157,9 @@
             <template is="dom-if" if="[[item.date]]">
               <gr-date-formatter date-str="[[item.date]]"></gr-date-formatter>
             </template>
+            <template is="dom-if" if="[[item.file]]">
+              <gr-file-status-chip file="[[item.file]]"></gr-file-status-chip>
+            </template>
           </div>
           <template is="dom-if" if="[[item.bottomText]]">
             <div class="bottomContent">
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
index e3d7ed70..909a3bbf 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
@@ -24,9 +24,6 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
     element = basicFixture.instantiate();
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index d64b1c0..a4a6fe3 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -17,12 +17,9 @@
 import '@polymer/iron-dropdown/iron-dropdown';
 import '../gr-button/gr-button';
 import '../gr-cursor-manager/gr-cursor-manager';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../../../styles/shared-styles';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-dropdown_html';
 import {getBaseUrl} from '../../../utils/url-util';
@@ -43,7 +40,6 @@
 export interface GrDropdown {
   $: {
     dropdown: IronDropdownElement;
-    cursor: GrCursorManager;
   };
 }
 
@@ -67,9 +63,7 @@
 }
 
 @customElement('gr-dropdown')
-export class GrDropdown extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrDropdown extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -108,6 +102,10 @@
   @property({type: Number})
   verticalOffset = 40;
 
+  /** Propagates/Reflects the `opened` property of the <iron-dropdown> */
+  @property({type: Boolean, notify: true})
+  opened = false;
+
   /**
    * List the IDs of dropdown buttons to be disabled. (Note this only
    * disables buttons and not link entries.)
@@ -115,12 +113,6 @@
   @property({type: Array})
   disabledIds: string[] = [];
 
-  /**
-   * The elements of the list.
-   */
-  @property({type: Array})
-  _listElements: Element[] = [];
-
   get keyBindings() {
     return {
       down: '_handleDown',
@@ -130,6 +122,20 @@
     };
   }
 
+  private cursor = new GrCursorManager();
+
+  constructor() {
+    super();
+    this.cursor.cursorTargetClass = 'selected';
+    this.cursor.focusOnMove = true;
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    this.cursor.unsetCursor();
+    super.disconnectedCallback();
+  }
+
   /**
    * Handle the up key.
    */
@@ -137,7 +143,7 @@
     if (this.$.dropdown.opened) {
       e.preventDefault();
       e.stopPropagation();
-      this.$.cursor.previous();
+      this.cursor.previous();
     } else {
       this._open();
     }
@@ -150,7 +156,7 @@
     if (this.$.dropdown.opened) {
       e.preventDefault();
       e.stopPropagation();
-      this.$.cursor.next();
+      this.cursor.next();
     } else {
       this._open();
     }
@@ -177,8 +183,8 @@
       // TODO(milutin): This solution is not particularly robust in general.
       // Since gr-tooltip-content click on shadow dom is not propagated down,
       // we have to target `a` inside it.
-      if (this.$.cursor.target !== null) {
-        const el = this.$.cursor.target.querySelector(':not([hidden]) a');
+      if (this.cursor.target !== null) {
+        const el = this.cursor.target.querySelector(':not([hidden]) a');
         if (el) {
           (el as HTMLElement).click();
         }
@@ -195,6 +201,10 @@
     this._close();
   }
 
+  handleOpenedChanged(e: CustomEvent) {
+    this.opened = e.detail.value;
+  }
+
   /**
    * Handle a click on the button to open the dropdown.
    */
@@ -214,14 +224,14 @@
   _open() {
     this.$.dropdown.open();
     this._resetCursorStops();
-    this.$.cursor.setCursorAtIndex(0);
-    if (this.$.cursor.target !== null) this.$.cursor.target.focus();
+    this.cursor.setCursorAtIndex(0);
+    if (this.cursor.target !== null) this.cursor.target.focus();
   }
 
   _close() {
     // async is needed so that that the click event is fired before the
     // dropdown closes (This was a bug for touch devices).
-    this.async(() => {
+    setTimeout(() => {
       this.$.dropdown.close();
     }, 1);
   }
@@ -330,7 +340,7 @@
   _resetCursorStops() {
     if (this.items && this.items.length > 0 && this.$.dropdown.opened) {
       flush();
-      this._listElements =
+      this.cursor.stops =
         this.root !== null ? Array.from(this.root.querySelectorAll('li')) : [];
     }
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts
index 8ea0d21..e754c9c 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
@@ -28,8 +28,11 @@
     .dropdown-content {
       background-color: var(--dropdown-background-color);
       box-shadow: var(--elevation-level-2);
+      min-width: 112px;
+      max-width: 280px;
     }
     gr-button {
+      vertical-align: top;
       @apply --gr-button;
     }
     gr-avatar {
@@ -103,6 +106,7 @@
     allow-outside-scroll="true"
     horizontal-align="[[horizontalAlign]]"
     on-click="_handleDropdownClick"
+    on-opened-changed="handleOpenedChanged"
   >
     <div class="dropdown-content" slot="dropdown-content">
       <ul>
@@ -158,12 +162,4 @@
       </ul>
     </div>
   </iron-dropdown>
-  <gr-cursor-manager
-    id="cursor"
-    cursor-target-class="selected"
-    scroll-mode="never"
-    focus-on-move=""
-    stops="[[_listElements]]"
-  ></gr-cursor-manager>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
index 515644b..c271b41 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
@@ -25,9 +25,6 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
     element = basicFixture.instantiate();
   });
 
@@ -156,7 +153,7 @@
     });
 
     test('down', () => {
-      const stub = sinon.stub(element.$.cursor, 'next');
+      const stub = sinon.stub(element.cursor, 'next');
       assert.isFalse(element.$.dropdown.opened);
       MockInteractions.pressAndReleaseKeyOn(element, 40);
       assert.isTrue(element.$.dropdown.opened);
@@ -165,7 +162,7 @@
     });
 
     test('up', () => {
-      const stub = sinon.stub(element.$.cursor, 'previous');
+      const stub = sinon.stub(element.cursor, 'previous');
       assert.isFalse(element.$.dropdown.opened);
       MockInteractions.pressAndReleaseKeyOn(element, 38);
       assert.isTrue(element.$.dropdown.opened);
@@ -180,7 +177,7 @@
       MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
       assert.isTrue(element.$.dropdown.opened);
 
-      const el = element.$.cursor.target.querySelector(':not([hidden]) a');
+      const el = element.cursor.target.querySelector(':not([hidden]) a');
       const stub = sinon.stub(el, 'click');
       MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
       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 90aaa9f..eb44a60 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
@@ -16,14 +16,13 @@
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
-import '../gr-storage/gr-storage';
 import '../gr-button/gr-button';
-import {GrStorage} from '../gr-storage/gr-storage';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-editable-content_html';
+import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {debounce, DelayedTask} from '../../../utils/async-util';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -34,16 +33,8 @@
   }
 }
 
-export interface GrEditableContent {
-  $: {
-    storage: GrStorage;
-  };
-}
-
 @customElement('gr-editable-content')
-export class GrEditableContent extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrEditableContent extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -72,7 +63,7 @@
   @property({type: Boolean, reflectToAttribute: true})
   disabled = false;
 
-  @property({type: Boolean, observer: '_editingChanged'})
+  @property({type: Boolean, observer: '_editingChanged', notify: true})
   editing = false;
 
   @property({type: Boolean})
@@ -82,6 +73,29 @@
   @property({type: String})
   storageKey?: string;
 
+  /** If false, then the "Show more" button was used to expand. */
+  @property({type: Boolean})
+  _commitCollapsed = true;
+
+  @property({type: Boolean})
+  commitCollapsible = true;
+
+  @property({
+    type: Boolean,
+    computed:
+      '_computeHideShowAllContainer(hideEditCommitMessage, _hideShowAllButton, editing)',
+  })
+  _hideShowAllContainer = false;
+
+  @property({
+    type: Boolean,
+    computed: '_computeHideShowAllButton(commitCollapsible, editing)',
+  })
+  _hideShowAllButton = false;
+
+  @property({type: Boolean})
+  hideEditCommitMessage?: boolean;
+
   @property({
     type: Boolean,
     computed: '_computeSaveDisabled(disabled, content, _newContent)',
@@ -91,6 +105,23 @@
   @property({type: String, observer: '_newContentChanged'})
   _newContent?: string;
 
+  private readonly storage = appContext.storageService;
+
+  private readonly reporting = appContext.reportingService;
+
+  private storeTask?: DelayedTask;
+
+  /** @override */
+  ready() {
+    super.ready();
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    this.storeTask?.cancel();
+    super.disconnectedCallback();
+  }
+
   _contentChanged() {
     /* A changed content means that either a different change has been loaded
      * or new content was saved. Either way, let's reset the component.
@@ -107,18 +138,18 @@
     if (!this.storageKey) return;
     const storageKey = this.storageKey;
 
-    this.debounce(
-      'store',
+    this.storeTask = debounce(
+      this.storeTask,
       () => {
         if (newContent.length) {
-          this.$.storage.setEditableContentItem(storageKey, newContent);
+          this.storage.setEditableContentItem(storageKey, newContent);
         } else {
           // This does not really happen, because we don't clear newContent
           // after saving (see below). So this only occurs when the user clears
-          // all the content in the editable textarea. But <gr-storage> cleans
+          // all the content in the editable textarea. But GrStorage cleans
           // up itself after one day, so we are not so concerned about leaving
           // some garbage behind.
-          this.$.storage.eraseEditableContentItem(storageKey);
+          this.storage.eraseEditableContentItem(storageKey);
         }
       },
       STORAGE_DEBOUNCE_INTERVAL_MS
@@ -144,18 +175,12 @@
 
     let content;
     if (this.storageKey) {
-      const storedContent = this.$.storage.getEditableContentItem(
+      const storedContent = this.storage.getEditableContentItem(
         this.storageKey
       );
       if (storedContent?.message) {
         content = storedContent.message;
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: RESTORED_MESSAGE},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(this, RESTORED_MESSAGE);
       }
     }
     if (!content) {
@@ -193,11 +218,43 @@
   _handleCancel(e: Event) {
     e.preventDefault();
     this.editing = false;
-    this.dispatchEvent(
-      new CustomEvent('editable-content-cancel', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'editable-content-cancel');
+  }
+
+  _computeCollapseText(collapsed: boolean) {
+    return collapsed ? 'Show all' : 'Show less';
+  }
+
+  _toggleCommitCollapsed() {
+    this._commitCollapsed = !this._commitCollapsed;
+    this.reporting.reportInteraction('toggle show all button', {
+      sectionName: 'Commit message',
+      toState: !this._commitCollapsed ? 'Show all' : 'Show less',
+    });
+    if (this._commitCollapsed) {
+      window.scrollTo(0, 0);
+    }
+  }
+
+  _computeHideShowAllContainer(
+    hideEditCommitMessage?: boolean,
+    _hideShowAllButton?: boolean,
+    editing?: boolean
+  ) {
+    if (editing) return false;
+    return _hideShowAllButton && hideEditCommitMessage;
+  }
+
+  _computeHideShowAllButton(commitCollapsible?: boolean, editing?: boolean) {
+    return !commitCollapsible || editing;
+  }
+
+  _computeCommitMessageCollapsed(collapsed?: boolean, collapsible?: boolean) {
+    return collapsible && collapsed;
+  }
+
+  _handleEditCommitMessage() {
+    this.editing = true;
+    this.focusTextarea();
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
index 81a2c2f..c6ff903 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
@@ -31,13 +31,19 @@
       box-shadow: var(--elevation-level-1);
       padding: var(--spacing-m);
     }
-    :host([collapsed]) .viewer {
-      max-height: 36em;
+    :host([collapsed]) .viewer,
+    .viewer[collapsed] {
+      max-height: var(--collapsed-max-height, 300px);
       overflow: hidden;
     }
+    .editor iron-autogrow-textarea,
+    .viewer {
+      min-height: 100px;
+    }
     .editor iron-autogrow-textarea {
       background-color: var(--view-background-color);
       width: 100%;
+      display: block;
 
       /* You have to also repeat everything from shared-styles here, because
            you can only *replace* --iron-autogrow-textarea vars as a whole. */
@@ -52,24 +58,96 @@
       display: flex;
       justify-content: space-between;
     }
+    .show-all-container {
+      background-color: var(--view-background-color);
+      display: flex;
+      justify-content: flex-end;
+      border-top-width: 1px;
+      border-top-style: solid;
+      border-radius: 0 0 4px 4px;
+      border-color: var(--border-color);
+      box-shadow: var(--elevation-level-1);
+      /* slightly up to cover rounded corner of the commit msg */
+      margin-top: calc(-1 * var(--spacing-xs));
+      /* To make this bar pop over editor, since editor has relative position. 
+      */
+      position: relative;
+    }
+    .show-all-container .show-all-button {
+      margin-right: auto;
+    }
+    .show-all-container iron-icon {
+      color: inherit;
+      --iron-icon-height: 18px;
+      --iron-icon-width: 18px;
+    }
+    .cancel-button {
+      margin-right: var(--spacing-l);
+    }
+    .save-button {
+      margin-right: var(--spacing-xs);
+    }
+    gr-button {
+      font-family: var(--font-family);
+      line-height: var(--line-height-normal);
+      padding: var(--spacing-xs);
+    }
   </style>
-  <div class="viewer" hidden$="[[editing]]">
+  <div
+    class="viewer"
+    hidden$="[[editing]]"
+    collapsed$="[[_computeCommitMessageCollapsed(_commitCollapsed, commitCollapsible)]]"
+  >
     <slot></slot>
   </div>
   <div class="editor" hidden$="[[!editing]]">
-    <iron-autogrow-textarea
-      autocomplete="on"
-      bind-value="{{_newContent}}"
-      disabled="[[disabled]]"
-    ></iron-autogrow-textarea>
-    <div class="editButtons">
-      <gr-button primary="" on-click="_handleSave" disabled="[[_saveDisabled]]"
-        >Save</gr-button
-      >
-      <gr-button on-click="_handleCancel" disabled="[[disabled]]"
+    <div>
+      <iron-autogrow-textarea
+        autocomplete="on"
+        bind-value="{{_newContent}}"
+        disabled="[[disabled]]"
+      ></iron-autogrow-textarea>
+    </div>
+  </div>
+  <div class="show-all-container" hidden$="[[_hideShowAllContainer]]">
+    <gr-button
+      link=""
+      class="show-all-button"
+      on-click="_toggleCommitCollapsed"
+      hidden$="[[_hideShowAllButton]]"
+      ><iron-icon
+        icon="gr-icons:expand-more"
+        hidden$="[[!_commitCollapsed]]"
+      ></iron-icon
+      ><iron-icon
+        icon="gr-icons:expand-less"
+        hidden$="[[_commitCollapsed]]"
+      ></iron-icon>
+      [[_computeCollapseText(_commitCollapsed)]]
+    </gr-button>
+    <gr-button
+      link=""
+      class="edit-commit-message"
+      title="Edit commit message"
+      on-click="_handleEditCommitMessage"
+      hidden$="[[hideEditCommitMessage]]"
+      ><iron-icon icon="gr-icons:edit"></iron-icon> Edit</gr-button
+    >
+    <div class="editButtons" hidden$="[[!editing]]">
+      <gr-button
+        link=""
+        class="cancel-button"
+        on-click="_handleCancel"
+        disabled="[[disabled]]"
         >Cancel</gr-button
       >
+      <gr-button
+        class="save-button"
+        primary=""
+        on-click="_handleSave"
+        disabled="[[_saveDisabled]]"
+        >Save</gr-button
+      >
     </div>
   </div>
-  <gr-storage id="storage"></gr-storage>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
index 129fda8..94a7b96 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
@@ -45,7 +45,7 @@
     element.addEventListener('editable-content-cancel', handler);
 
     MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button:not([primary])'));
+        .querySelector('gr-button.cancel-button'));
 
     assert.isTrue(handler.called);
   });
@@ -99,34 +99,34 @@
     });
 
     test('editing toggled to true, has stored data', () => {
-      sinon.stub(element.$.storage, 'getEditableContentItem')
+      sinon.stub(element.storage, 'getEditableContentItem')
           .returns({message: 'stored content'});
       element.editing = true;
 
       assert.equal(element._newContent, 'stored content');
       assert.isTrue(dispatchSpy.called);
-      assert.equal(dispatchSpy.lastCall.args[0].type, 'show-alert');
+      assert.equal(dispatchSpy.firstCall.args[0].type, 'show-alert');
     });
 
     test('editing toggled to true, has no stored data', () => {
-      sinon.stub(element.$.storage, 'getEditableContentItem')
+      sinon.stub(element.storage, 'getEditableContentItem')
           .returns({});
       element.editing = true;
 
       assert.equal(element._newContent, 'current content');
-      assert.isFalse(dispatchSpy.called);
+      assert.equal(dispatchSpy.firstCall.args[0].type, 'editing-changed');
     });
 
     test('edits are cached', () => {
       const storeStub =
-          sinon.stub(element.$.storage, 'setEditableContentItem');
+          sinon.stub(element.storage, 'setEditableContentItem');
       const eraseStub =
-          sinon.stub(element.$.storage, 'eraseEditableContentItem');
+          sinon.stub(element.storage, 'eraseEditableContentItem');
       element.editing = true;
 
       element._newContent = 'new content';
       flush();
-      element.flushDebouncer('store');
+      element.storeTask.flush();
 
       assert.isTrue(storeStub.called);
       assert.deepEqual(
@@ -135,7 +135,7 @@
 
       element._newContent = '';
       flush();
-      element.flushDebouncer('store');
+      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 9e1a5bf..c758455 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
@@ -18,8 +18,7 @@
 import '@polymer/paper-input/paper-input';
 import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+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';
@@ -28,6 +27,10 @@
 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';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -40,15 +43,12 @@
 
 export interface GrEditableLabel {
   $: {
-    input: PaperInputElementExt;
     dropdown: IronDropdownElement;
   };
 }
 
 @customElement('gr-editable-label')
-export class GrEditableLabel extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrEditableLabel extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -89,6 +89,15 @@
   @property({type: Number})
   readonly _verticalOffset = -30;
 
+  @property({type: Boolean})
+  showAsEditPencil = false;
+
+  @property({type: Boolean})
+  autocomplete = false;
+
+  @property({type: Object})
+  query?: AutocompleteQuery;
+
   /** @override */
   ready() {
     super.ready();
@@ -117,8 +126,9 @@
     if (this.readOnly || this.editing) return;
     return this._open().then(() => {
       this._nativeInput.focus();
-      if (!this.$.input.value) return;
-      this._nativeInput.setSelectionRange(0, this.$.input.value.length);
+      const input = this.getInput();
+      if (!input?.value) return;
+      this._nativeInput.setSelectionRange(0, input.value.length);
     });
   }
 
@@ -130,10 +140,10 @@
 
   _open() {
     this.$.dropdown.open();
-    this._inputText = this.value;
+    this._inputText = this.value || '';
     this.editing = true;
 
-    return new Promise(resolve => {
+    return new Promise<void>(resolve => {
       this._awaitOpen(resolve);
     });
   }
@@ -145,7 +155,7 @@
   _awaitOpen(fn: () => void) {
     let iters = 0;
     const step = () => {
-      this.async(() => {
+      setTimeout(() => {
         if (this.$.dropdown.style.display !== 'none') {
           fn.call(this);
         } else if (iters++ < AWAIT_MAX_ITERS) {
@@ -187,8 +197,9 @@
 
   get _nativeInput(): HTMLInputElement {
     // In Polymer 2 inputElement isn't nativeInput anymore
-    return (this.$.input.$.nativeInput ||
-      this.$.input.inputElement) as HTMLInputElement;
+    return (this.getInput()?.$.nativeInput ||
+      this.getInput()?.inputElement ||
+      this.getGrAutocomplete()) as HTMLInputElement;
   }
 
   _handleEnter(e: CustomKeyboardEvent) {
@@ -209,6 +220,10 @@
     }
   }
 
+  _handleCommit() {
+    this._save();
+  }
+
   _computeLabelClass(readOnly?: boolean, value?: string, placeholder?: string) {
     const classes = [];
     if (!readOnly) {
@@ -223,4 +238,12 @@
   _updateTitle(value?: string) {
     this.setAttribute('title', this._computeLabel(value, this.placeholder));
   }
+
+  getInput(): PaperInputElementExt | null {
+    return this.shadowRoot!.querySelector<PaperInputElementExt>('#input');
+  }
+
+  getGrAutocomplete(): GrAutocomplete | null {
+    return this.shadowRoot!.querySelector<GrAutocomplete>('#autocomplete');
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
index 5e36166..a227482 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
@@ -68,13 +68,33 @@
       }
       --paper-input-container-focus-color: var(--link-color);
     }
+    gr-button iron-icon {
+      color: inherit;
+      --iron-icon-height: 18px;
+      --iron-icon-width: 18px;
+    }
+    gr-button.pencil {
+      --padding: 0px 0px;
+    }
   </style>
-  <label
-    class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
-    title$="[[_computeLabel(value, placeholder)]]"
-    on-click="_showDropdown"
-    >[[_computeLabel(value, placeholder)]]</label
-  >
+  <template is="dom-if" if="[[!showAsEditPencil]]">
+    <label
+      class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
+      title$="[[_computeLabel(value, placeholder)]]"
+      aria-label$="[[_computeLabel(value, placeholder)]]"
+      on-click="_showDropdown"
+      >[[_computeLabel(value, placeholder)]]</label
+    >
+  </template>
+  <template is="dom-if" if="[[showAsEditPencil]]">
+    <gr-button
+      link=""
+      class$="pencil [[_computeLabelClass(readOnly, value, placeholder)]]"
+      on-click="_showDropdown"
+      title="[[_computeLabel(value, placeholder)]]"
+      ><iron-icon icon="gr-icons:edit"></iron-icon
+    ></gr-button>
+  </template>
   <iron-dropdown
     id="dropdown"
     vertical-align="auto"
@@ -85,12 +105,24 @@
   >
     <div class="dropdown-content" slot="dropdown-content">
       <div class="inputContainer">
-        <paper-input
-          id="input"
-          label="[[labelText]]"
-          maxlength="[[maxLength]]"
-          value="{{_inputText}}"
-        ></paper-input>
+        <template is="dom-if" if="[[!autocomplete]]">
+          <paper-input
+            id="input"
+            label="[[labelText]]"
+            maxlength="[[maxLength]]"
+            value="{{_inputText}}"
+          ></paper-input>
+        </template>
+        <template is="dom-if" if="[[autocomplete]]">
+          <gr-autocomplete
+            label="[[labelText]]"
+            id="autocomplete"
+            text="{{_inputText}}"
+            query="[[query]]"
+            on-commit="_handleCommit"
+          >
+          </gr-autocomplete>
+        </template>
         <div class="buttons">
           <gr-button link="" id="cancelBtn" on-click="_cancel"
             >cancel</gr-button
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
index d8f085e..3e217f0 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
@@ -45,11 +45,13 @@
   setup(async () => {
     element = basicFixture.instantiate();
     elementNoPlaceholder = noPlaceholderFixture.instantiate();
+    flush();
     label = element.shadowRoot.querySelector('label');
 
     await flush();
     // In Polymer 2 inputElement isn't nativeInput anymore
-    input = element.$.input.$.nativeInput || element.$.input.inputElement;
+    const paperInput = element.shadowRoot.querySelector('#input');
+    input = paperInput.$.nativeInput || paperInput.inputElement;
   });
 
   test('element render', () => {
@@ -178,6 +180,7 @@
 
     setup(() => {
       element = readOnlyFixture.instantiate();
+      flush();
       label = element.shadowRoot
           .querySelector('label');
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts
new file mode 100644
index 0000000..cfa67fd
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts
@@ -0,0 +1,86 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import {htmlTemplate} from './gr-file-status-chip_html';
+import {customElement, property} from '@polymer/decorators';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {SpecialFilePath} from '../../../constants/constants';
+import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const FileStatus = {
+  A: 'Added',
+  C: 'Copied',
+  D: 'Deleted',
+  M: 'Modified',
+  R: 'Renamed',
+  W: 'Rewritten',
+  U: 'Unchanged',
+};
+
+@customElement('gr-file-status-chip')
+export class GrFileStatusChip extends PolymerElement {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  file?: NormalizedFileInfo;
+
+  /**
+   * Get a descriptive label for use in the status indicator's tooltip and
+   * ARIA label.
+   */
+  _computeFileStatusLabel(status?: keyof typeof FileStatus) {
+    const statusCode = this._computeFileStatus(status);
+    return hasOwnProperty(FileStatus, statusCode)
+      ? FileStatus[statusCode]
+      : 'Status Unknown';
+  }
+
+  _computeClass(baseClass?: string, path?: string) {
+    const classes = [];
+    if (baseClass) {
+      classes.push(baseClass);
+    }
+    if (
+      path === SpecialFilePath.COMMIT_MESSAGE ||
+      path === SpecialFilePath.MERGE_LIST
+    ) {
+      classes.push('invisible');
+    }
+    return classes.join(' ');
+  }
+
+  _computeFileStatus(
+    status?: keyof typeof FileStatus
+  ): keyof typeof FileStatus {
+    return status || 'M';
+  }
+
+  _computeStatusClass(file?: NormalizedFileInfo) {
+    if (!file) return '';
+    const classStr = this._computeClass('status', file.__path);
+    return `${classStr} ${this._computeFileStatus(file.status)}`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-file-status-chip': GrFileStatusChip;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_html.ts b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_html.ts
new file mode 100644
index 0000000..6c7f3e0a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_html.ts
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .status {
+      display: inline-block;
+      border-radius: var(--border-radius);
+      margin-left: var(--spacing-s);
+      padding: 0 var(--spacing-m);
+      color: var(--primary-text-color);
+      font-size: var(--font-size-small);
+      background-color: var(--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-file-status-chip/gr-file-status-chip_test.ts b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_test.ts
new file mode 100644
index 0000000..0abc85f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_test.ts
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-file-status-chip';
+import {GrFileStatusChip} from './gr-file-status-chip';
+
+const fixture = fixtureFromElement('gr-file-status-chip');
+
+suite('gr-file-status-chip tests', () => {
+  let element: GrFileStatusChip;
+
+  setup(() => {
+    element = fixture.instantiate();
+  });
+
+  test('computed properties', () => {
+    assert.equal(element._computeFileStatus('A'), 'A');
+    assert.equal(element._computeFileStatus(undefined), 'M');
+
+    assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
+    assert.equal(
+      element._computeClass('clazz', '/COMMIT_MSG'),
+      'clazz invisible'
+    );
+  });
+
+  test('_computeFileStatusLabel', () => {
+    assert.equal(element._computeFileStatusLabel('A'), 'Added');
+    assert.equal(element._computeFileStatusLabel('M'), 'Modified');
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index a6c7b92..17b659f 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
@@ -16,8 +16,6 @@
  */
 import '../gr-linked-text/gr-linked-text';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-formatted-text_html';
@@ -45,9 +43,7 @@
 }
 
 @customElement('gr-formatted-text')
-export class GrFormattedText extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrFormattedText extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
index bc1dfe0..04e4954 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
@@ -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;
       font-family: var(--font-family);
@@ -59,7 +59,8 @@
     gr-linked-text.pre {
       font-family: var(--monospace-font-family);
       font-size: var(--font-size-code);
-      line-height: var(--line-height-code);
+      /* usually 16px = 12px + 4px */
+      line-height: calc(var(--font-size-code) + var(--spacing-s));
     }
   </style>
   <div id="container"></div>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index da2881e..abd4469 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -19,17 +19,13 @@
 import '../../../styles/shared-styles';
 import '../gr-avatar/gr-avatar';
 import '../gr-button/gr-button';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import {hovercardBehaviorMixin} from '../gr-hovercard/gr-hovercard-behavior';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-hovercard-account_html';
 import {appContext} from '../../../services/app-context';
-import {accountKey} from '../../../utils/account-util';
+import {accountKey, isSelf} from '../../../utils/account-util';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   AccountInfo,
   ChangeInfo,
@@ -45,17 +41,12 @@
   isAttentionSetEnabled,
 } from '../../../utils/attention-set-util';
 import {ReviewerState} from '../../../constants/constants';
-import {isRemovableReviewer} from '../../../utils/change-util';
+import {CURRENT} from '../../../utils/patch-set-util';
+import {isInvolved, isRemovableReviewer} from '../../../utils/change-util';
+import {assertIsDefined} from '../../../utils/common-util';
 
-export interface GrHovercardAccount {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-hovercard-account')
-export class GrHovercardAccount extends GestureEventListeners(
-  hovercardBehaviorMixin(LegacyElementMixin(PolymerElement))
-) {
+export class GrHovercardAccount extends hovercardBehaviorMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -94,24 +85,26 @@
 
   reporting: ReportingService;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this.reporting = appContext.reportingService;
   }
 
-  attached() {
-    super.attached();
-    this.$.restAPI.getConfig().then(config => {
+  connectedCallback() {
+    super.connectedCallback();
+    this.restApiService.getConfig().then(config => {
       this._config = config;
     });
-    this.$.restAPI.getAccount().then(account => {
+    this.restApiService.getAccount().then(account => {
       this._selfAccount = account;
     });
   }
 
   _computeText(account?: AccountInfo, selfAccount?: AccountInfo) {
     if (!account || !selfAccount) return '';
-    return account._account_id === selfAccount._account_id ? 'Your' : 'Their';
+    return isSelf(account, selfAccount) ? 'Your' : 'Their';
   }
 
   get isAttentionEnabled() {
@@ -142,9 +135,7 @@
   _getReviewerState(account: AccountInfo, change: ChangeInfo) {
     if (
       change.reviewers[ReviewerState.REVIEWER]?.some(
-        (reviewer: AccountInfo) => {
-          return reviewer._account_id === account._account_id;
-        }
+        (reviewer: AccountInfo) => reviewer._account_id === account._account_id
       )
     ) {
       return ReviewerState.REVIEWER;
@@ -167,7 +158,7 @@
   }
 
   _handleChangeReviewerOrCCStatus() {
-    if (!this.change) throw new Error('expected change object to be present');
+    assertIsDefined(this.change, 'change');
     // accountKey() throws an error if _account_id & email is not found, which
     // we want to check before showing reloading toast
     const _accountKey = accountKey(this.account);
@@ -185,8 +176,8 @@
       },
     ];
 
-    this.$.restAPI
-      .saveChangeReview(this.change._number, 'current', reviewInput)
+    this.restApiService
+      .saveChangeReview(this.change._number, CURRENT, reviewInput)
       .then(response => {
         if (!response || !response.ok) {
           throw new Error(
@@ -204,7 +195,7 @@
     this.dispatchEventThroughTarget('show-alert', {
       message: 'Reloading page...',
     });
-    this.$.restAPI
+    this.restApiService
       .removeChangeReviewer(
         this.change._number,
         (this.account?._account_id || this.account?.email)!
@@ -223,15 +214,17 @@
   }
 
   _computeShowActionAddToAttentionSet() {
-    return (
-      this._selfAccount && this.isAttentionEnabled && !this.hasUserAttention
-    );
+    const involvedOrSelf =
+      isInvolved(this.change, this._selfAccount) ||
+      isSelf(this.account, this._selfAccount);
+    return involvedOrSelf && this.isAttentionEnabled && !this.hasUserAttention;
   }
 
   _computeShowActionRemoveFromAttentionSet() {
-    return (
-      this._selfAccount && this.isAttentionEnabled && this.hasUserAttention
-    );
+    const involvedOrSelf =
+      isInvolved(this.change, this._selfAccount) ||
+      isSelf(this.account, this._selfAccount);
+    return involvedOrSelf && this.isAttentionEnabled && this.hasUserAttention;
   }
 
   _handleClickAddToAttentionSet() {
@@ -256,7 +249,7 @@
       'attention-hovercard-add',
       this._reportingDetails()
     );
-    this.$.restAPI
+    this.restApiService
       .addToAttentionSet(this.change._number, this.account._account_id, reason)
       .then(() => {
         this.dispatchEventThroughTarget('hide-alert');
@@ -283,7 +276,7 @@
       'attention-hovercard-remove',
       this._reportingDetails()
     );
-    this.$.restAPI
+    this.restApiService
       .removeFromAttentionSet(
         this.change._number,
         this.account._account_id,
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
index 1d437fb..d0986de 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
@@ -111,15 +111,6 @@
               [[_computeText(account, _selfAccount)]] turn to take action.
             </span>
             <a
-              href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set"
-              target="_blank"
-            >
-              <iron-icon
-                icon="gr-icons:bug"
-                title="report a problem"
-              ></iron-icon>
-            </a>
-            <a
               href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
               target="_blank"
             >
@@ -144,7 +135,7 @@
       </template>
       <template
         is="dom-if"
-        if="[[_computeShowActionAddToAttentionSet(_config, highlightAttention, account, change)]]"
+        if="[[_computeShowActionAddToAttentionSet(_config, highlightAttention, account, change, _selfAccount)]]"
       >
         <div class="action">
           <gr-button
@@ -159,7 +150,7 @@
       </template>
       <template
         is="dom-if"
-        if="[[_computeShowActionRemoveFromAttentionSet(_config, highlightAttention, account, change)]]"
+        if="[[_computeShowActionRemoveFromAttentionSet(_config, highlightAttention, account, change, _selfAccount)]]"
       >
         <div class="action">
           <gr-button
@@ -172,7 +163,10 @@
           </gr-button>
         </div>
       </template>
-      <template is="dom-if" if="[[_showReviewerOrCCActions(account, change)]]">
+      <template
+        is="dom-if"
+        if="[[_showReviewerOrCCActions(account, change, _selfAccount)]]"
+      >
         <div class="action">
           <gr-button
             class="removeReviewerOrCC"
@@ -196,5 +190,4 @@
       </template>
     </template>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
index b09f0ce..1c67e13 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
@@ -19,6 +19,7 @@
 import './gr-hovercard-account.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {ReviewerState} from '../../../constants/constants.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromTemplate(html`
 <gr-hovercard-account class="hovered"></gr-hovercard-account>
@@ -34,22 +35,20 @@
     _account_id: '31415926535',
   };
 
-  setup(() => {
-    element = basicFixture.instantiate();
-    sinon.stub(element.$.restAPI, 'getAccount').returns(
-        new Promise(resolve => { '2'; })
+  setup(async () => {
+    stubRestApi('getAccount').returns(Promise.resolve({...ACCOUNT}));
+    stubRestApi('getConfig').returns(
+        Promise.resolve({change: {enable_attention_set: true}})
     );
-
-    element._selfAccount = {...ACCOUNT};
+    element = basicFixture.instantiate();
     element.account = {...ACCOUNT};
-    element._config = {
-      change: {enable_attention_set: true},
-    };
     element.change = {
       attention_set: {},
+      reviewers: {},
+      owner: {...ACCOUNT},
     };
     element.show({});
-    flush();
+    await flush();
   });
 
   teardown(() => {
@@ -110,8 +109,7 @@
         [ReviewerState.REVIEWER]: [ACCOUNT],
       },
     };
-    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
-        Promise.resolve({ok: true}));
+    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
     const reloadListener = sinon.spy();
     element._target.addEventListener('reload', reloadListener);
     flush();
@@ -130,11 +128,10 @@
         [ReviewerState.REVIEWER]: [ACCOUNT],
       },
     };
-    const saveReviewStub = sinon.stub(element.$.restAPI,
+    const saveReviewStub = stubRestApi(
         'saveChangeReview').returns(
         Promise.resolve({ok: true}));
-    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
-        Promise.resolve({ok: true}));
+    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
     const reloadListener = sinon.spy();
     element._target.addEventListener('reload', reloadListener);
 
@@ -157,11 +154,9 @@
         [ReviewerState.REVIEWER]: [],
       },
     };
-    const saveReviewStub = sinon.stub(element.$.restAPI,
-        'saveChangeReview').returns(
-        Promise.resolve({ok: true}));
-    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
-        Promise.resolve({ok: true}));
+    const saveReviewStub = stubRestApi(
+        'saveChangeReview').returns(Promise.resolve({ok: true}));
+    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
     const reloadListener = sinon.spy();
     element._target.addEventListener('reload', reloadListener);
     flush();
@@ -184,8 +179,7 @@
         [ReviewerState.REVIEWER]: [],
       },
     };
-    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
-        Promise.resolve({ok: true}));
+    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
     const reloadListener = sinon.spy();
     element._target.addEventListener('reload', reloadListener);
 
@@ -206,8 +200,7 @@
     const apiPromise = new Promise(r => {
       apiResolve = r;
     });
-    sinon.stub(element.$.restAPI, 'addToAttentionSet')
-        .callsFake(() => apiPromise);
+    stubRestApi('addToAttentionSet').returns(apiPromise);
     element.highlightAttention = true;
     element._target = document.createElement('div');
     flush();
@@ -239,10 +232,13 @@
     const apiPromise = new Promise(r => {
       apiResolve = r;
     });
-    sinon.stub(element.$.restAPI, 'removeFromAttentionSet')
-        .callsFake(() => apiPromise);
+    stubRestApi('removeFromAttentionSet').returns(apiPromise);
     element.highlightAttention = true;
-    element.change = {attention_set: {31415926535: {}}};
+    element.change = {
+      attention_set: {31415926535: {}},
+      reviewers: {},
+      owner: {...ACCOUNT},
+    };
     element._target = document.createElement('div');
     flush();
     const showAlertListener = sinon.spy();
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
index 78b6cda..48de78e 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -16,24 +16,17 @@
  */
 import '../../../styles/shared-styles';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {Debouncer} from '@polymer/polymer/lib/utils/debounce';
-import {timeOut} from '@polymer/polymer/lib/utils/async';
 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 {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {
   pushScrollLock,
   removeScrollLock,
 } from '@polymer/iron-overlay-behavior/iron-scroll-manager';
-
-interface ShowAlertEventDetail {
-  message: string;
-  dismissOnNavigation?: boolean;
-}
-
+import {ShowAlertEventDetail} from '../../../types/events';
+import {debounce, DelayedTask} from '../../../utils/async-util';
 interface ReloadEventDetail {
   clearPatchset?: boolean;
 }
@@ -42,6 +35,26 @@
 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?
  */
@@ -60,11 +73,8 @@
  *
  * @example
  *
- * // LegacyElementMixin is still needed to support the old lifecycles
- * // TODO: Replace old life cycles with new ones.
- *
  * class YourComponent extends hovercardBehaviorMixin(
- *  LegacyElementMixin(PolymerElement)
+ *  PolymerElement
  *
  * @see gr-hovercard.ts
  *
@@ -73,7 +83,7 @@
  * @mixinFunction
  */
 export const hovercardBehaviorMixin = dedupingMixin(
-  <T extends Constructor<PolymerElement & LegacyElementMixin>>(
+  <T extends Constructor<PolymerElement>>(
     superClass: T
   ): T & Constructor<GrHovercardBehaviorInterface> => {
     /**
@@ -82,7 +92,7 @@
      */
     class Mixin extends superClass {
       @property({type: Object})
-      _target: Element | null = null;
+      _target: HTMLElement | null = null;
 
       // Determines whether or not the hovercard is visible.
       @property({type: Boolean})
@@ -109,92 +119,79 @@
       @property({type: Object})
       container: HTMLElement | null = null;
 
-      /**
-       * ID for the container element.
-       */
-      @property({type: String})
-      containerId = 'gr-hovercard-container';
+      private hideTask?: DelayedTask;
 
-      private _hideDebouncer: Debouncer | null = null;
+      private showTask?: DelayedTask;
 
-      private _showDebouncer: Debouncer | null = null;
+      private isScheduledToShow?: boolean;
 
-      private _isScheduledToShow?: boolean;
-
-      private _isScheduledToHide?: boolean;
+      private isScheduledToHide?: boolean;
 
       /** @override */
-      attached() {
-        super.attached();
+      connectedCallback() {
+        super.connectedCallback();
         if (!this._target) {
           this._target = this.target;
         }
-        this.listen(this._target, 'mouseenter', 'debounceShow');
-        this.listen(this._target, 'focus', 'debounceShow');
-        this.listen(this._target, 'mouseleave', 'debounceHide');
-        this.listen(this._target, 'blur', 'debounceHide');
+        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.listen(this._target, 'click', 'hide');
+        this._target.addEventListener('click', this.hide);
 
         // show the hovercard if mouse moves to hovercard
         // this will cancel pending hide as well
-        this.listen(this, 'mouseenter', 'show');
-        this.listen(this, 'mouseenter', 'lock');
+        this.addEventListener('mouseenter', this.show);
+        this.addEventListener('mouseenter', this.lock);
         // when leave hovercard, hide it immediately
-        this.listen(this, 'mouseleave', 'hide');
-        this.listen(this, 'mouseleave', 'unlock');
+        this.addEventListener('mouseleave', this.hide);
+        this.addEventListener('mouseleave', this.unlock);
       }
 
-      detached() {
-        super.detached();
+      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 = getRootElement().querySelector('#' + this.containerId);
-
-        if (this.container) {
-          return;
-        }
-
-        // If it does not exist, create and initialize the hovercard container.
-        this.container = document.createElement('div');
-        this.container.setAttribute('id', this.containerId);
-        getRootElement().appendChild(this.container);
+        this.container = getHovercardContainer({createIfNotExists: true});
       }
 
       removeListeners() {
-        this.unlisten(this._target, 'mouseenter', 'debounceShow');
-        this.unlisten(this._target, 'focus', 'debounceShow');
-        this.unlisten(this._target, 'mouseleave', 'debounceHide');
-        this.unlisten(this._target, 'blur', 'debounceHide');
-        this.unlisten(this._target, 'click', 'hide');
+        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);
       }
 
-      debounceHide() {
-        this.cancelShowDebouncer();
-        if (!this._isShowing || this._isScheduledToHide) return;
-        this._isScheduledToHide = true;
-        this._hideDebouncer = Debouncer.debounce(
-          this._hideDebouncer,
-          timeOut.after(HIDE_DELAY_MS),
+      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;
+            if (!this.isScheduledToHide) return;
             this.hide();
-          }
+          },
+          HIDE_DELAY_MS
         );
-      }
+      };
 
-      cancelHideDebouncer() {
-        if (this._hideDebouncer) {
-          this._hideDebouncer.cancel();
-          this._isScheduledToHide = false;
+      cancelHideTask() {
+        if (this.hideTask) {
+          this.hideTask.cancel();
+          this.isScheduledToHide = false;
         }
       }
 
@@ -231,7 +228,7 @@
        * Returns the target element that the hovercard is anchored to (the `id` of
        * the `for` property).
        */
-      get target(): Element {
+      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;
@@ -244,15 +241,15 @@
               ? ownerRoot.host
               : parentNode;
         }
-        return target as Element;
+        return target as HTMLElement;
       }
 
       /**
        * unlock scroll, this will resume the scroll outside of the hovercard.
        */
-      unlock() {
+      readonly unlock = () => {
         removeScrollLock(this);
-      }
+      };
 
       /**
        * Hides/closes the hovercard. This occurs when the user triggers the
@@ -260,9 +257,9 @@
        * user is not hovering over the hovercard).
        *
        */
-      hide(e?: MouseEvent) {
-        this.cancelHideDebouncer();
-        this.cancelShowDebouncer();
+      readonly hide = (e?: MouseEvent) => {
+        this.cancelHideTask();
+        this.cancelShowTask();
         if (!this._isShowing) {
           return;
         }
@@ -294,54 +291,54 @@
         if (this.container?.contains(this)) {
           this.container.removeChild(this);
         }
-      }
+      };
 
       /**
        * Shows/opens the hovercard with a fixed delay.
        */
-      debounceShow() {
+      readonly debounceShow = () => {
         this.debounceShowBy(SHOW_DELAY_MS);
-      }
+      };
 
       /**
        * Shows/opens the hovercard with the given delay.
        */
       debounceShowBy(delayMs: number) {
-        this.cancelHideDebouncer();
-        if (this._isShowing || this._isScheduledToShow) return;
-        this._isScheduledToShow = true;
-        this._showDebouncer = Debouncer.debounce(
-          this._showDebouncer,
-          timeOut.after(delayMs),
+        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;
+            if (!this.isScheduledToShow) return;
             this.show();
-          }
+          },
+          delayMs
         );
       }
 
-      cancelShowDebouncer() {
-        if (this._showDebouncer) {
-          this._showDebouncer.cancel();
-          this._isScheduledToShow = false;
+      cancelShowTask() {
+        if (this.showTask) {
+          this.showTask.cancel();
+          this.isScheduledToShow = false;
         }
       }
 
       /**
        * Lock background scroll but enable scroll inside of current hovercard.
        */
-      lock() {
+      readonly lock = () => {
         pushScrollLock(this);
-      }
+      };
 
       /**
        * Shows/opens the hovercard. This occurs when the user triggers the
        * `mousenter` event on the hovercard's `target` element.
        */
-      show() {
-        this.cancelHideDebouncer();
-        this.cancelShowDebouncer();
+      readonly show = () => {
+        this.cancelHideTask();
+        this.cancelShowTask();
         if (this._isShowing || !this.container) {
           return;
         }
@@ -362,7 +359,7 @@
         flush();
         this.updatePosition();
         this.classList.remove(HIDE_CLASS);
-      }
+      };
 
       updatePosition() {
         const positionsToTry = new Set([
@@ -482,16 +479,15 @@
 );
 
 export interface GrHovercardBehaviorInterface {
-  attached(): void;
   ready(): void;
   removeListeners(): void;
   debounceHide(): void;
-  cancelHideDebouncer(): void;
+  cancelHideTask(): void;
   dispatchEventThroughTarget(eventName: string, detail?: unknown): void;
   hide(e?: MouseEvent): void;
   debounceShow(): void;
   debounceShowBy(delayMs: number): void;
-  cancelShowDebouncer(): void;
+  cancelShowTask(): void;
   show(): void;
   updatePosition(): void;
   updatePositionTo(position: string): void;
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 c56bc8f..8857d36 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
@@ -16,18 +16,14 @@
  */
 
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-hovercard_html';
 import {hovercardBehaviorMixin} from './gr-hovercard-behavior';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import './gr-hovercard-shared-style';
 import {customElement} from '@polymer/decorators';
 
 @customElement('gr-hovercard')
-export class GrHovercard extends GestureEventListeners(
-  hovercardBehaviorMixin(LegacyElementMixin(PolymerElement))
-) {
+export class GrHovercard extends hovercardBehaviorMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
index 6b2e620..27ef23f 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
@@ -120,18 +120,18 @@
     button.dispatchEvent(new CustomEvent('mouseenter'));
 
     await enterPromise;
-    assert.isTrue(element._isScheduledToShow);
-    element._showDebouncer.flush();
+    assert.isTrue(element.isScheduledToShow);
+    element.showTask.flush();
     assert.isTrue(element._isShowing);
-    assert.isFalse(element._isScheduledToShow);
+    assert.isFalse(element.isScheduledToShow);
 
     button.dispatchEvent(new CustomEvent('mouseleave'));
 
     await leavePromise;
-    assert.isTrue(element._isScheduledToHide);
+    assert.isTrue(element.isScheduledToHide);
     assert.isTrue(element._isShowing);
-    element._hideDebouncer.flush();
-    assert.isFalse(element._isScheduledToShow);
+    element.hideTask.flush();
+    assert.isFalse(element.isScheduledToShow);
     assert.isFalse(element._isShowing);
 
     button.removeEventListener('mouseenter', enterResolve);
@@ -152,11 +152,11 @@
     button.dispatchEvent(new CustomEvent('mouseenter'));
 
     await enterPromise;
-    assert.isTrue(element._isScheduledToShow);
+    assert.isTrue(element.isScheduledToShow);
     MockInteractions.tap(button);
 
     await clickPromise;
-    assert.isFalse(element._isScheduledToShow);
+    assert.isFalse(element.isScheduledToShow);
     assert.isFalse(element._isShowing);
 
     button.removeEventListener('mouseenter', enterResolve);
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 7112e3b..2259ca1 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -77,8 +77,10 @@
       <g id="content-copy"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="check"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="check-circle"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#check_circle-->
+      <g id="check-circle"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#check_circle_outline-->
+      <g id="check-circle-outline"><path d="M0 0h24v24H0V0zm0 0h24v24H0V0z" fill="none"/><path d="M16.59 7.58L10 14.17l-3.59-3.58L5 12l5 5 8-8zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="robot"><path d="M4.137453,5.61015591 L4.54835569,1.5340419 C4.5717665,1.30180904 4.76724872,1.12504213 5.00065859,1.12504213 C5.23327176,1.12504213 5.42730868,1.30282046 5.44761309,1.53454578 L5.76084628,5.10933916 C6.16304484,5.03749412 6.57714381,5 7,5 L17,5 C20.8659932,5 24,8.13400675 24,12 L24,15.1250421 C24,18.9910354 20.8659932,22.1250421 17,22.1250421 L7,22.1250421 C3.13400675,22.1250421 2.19029351e-15,18.9910354 0,15.1250421 L0,12 C-3.48556243e-16,9.15382228 1.69864167,6.70438358 4.137453,5.61015591 Z M5.77553049,6.12504213 C3.04904264,6.69038358 1,9.10590202 1,12 L1,15.1250421 C1,18.4387506 3.6862915,21.1250421 7,21.1250421 L17,21.1250421 C20.3137085,21.1250421 23,18.4387506 23,15.1250421 L23,12 C23,8.6862915 20.3137085,6 17,6 L7,6 C6.60617231,6 6.2212068,6.03794347 5.84855971,6.11037415 L5.84984496,6.12504213 L5.77553049,6.12504213 Z M6.93003717,6.95027711 L17.1232083,6.95027711 C19.8638332,6.95027711 22.0855486,9.17199258 22.0855486,11.9126175 C22.0855486,14.6532424 19.8638332,16.8749579 17.1232083,16.8749579 L6.93003717,16.8749579 C4.18941226,16.8749579 1.9676968,14.6532424 1.9676968,11.9126175 C1.9676968,9.17199258 4.18941226,6.95027711 6.93003717,6.95027711 Z M7.60124392,14.0779303 C9.03787127,14.0779303 10.2024878,12.9691885 10.2024878,11.6014862 C10.2024878,10.2337839 9.03787127,9.12504213 7.60124392,9.12504213 C6.16461657,9.12504213 5,10.2337839 5,11.6014862 C5,12.9691885 6.16461657,14.0779303 7.60124392,14.0779303 Z M16.617997,14.1098288 C18.0638768,14.1098288 19.2359939,12.9939463 19.2359939,11.6174355 C19.2359939,10.2409246 18.0638768,9.12504213 16.617997,9.12504213 C15.1721172,9.12504213 14,10.2409246 14,11.6174355 C14,12.9939463 15.1721172,14.1098288 16.617997,14.1098288 Z M9.79751216,18.1250421 L15,18.1250421 L15,19.1250421 C15,19.6773269 14.5522847,20.1250421 14,20.1250421 L10.7975122,20.1250421 C10.2452274,20.1250421 9.79751216,19.6773269 9.79751216,19.1250421 L9.79751216,18.1250421 Z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
@@ -112,6 +114,34 @@
       <g id="schedule"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#bug_report-->
       <g id="bug"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.gstatic.com/s/i/googlematerialicons/move_item/v1/24px.svg -->
+      <g id="move-item"><path d="M15,19H5V5h10v4h2V5c0-1.1-0.89-2-2-2H5C3.9,3,3,3.9,3,5v14c0,1.1,0.9,2,2,2h10c1.11,0,2-0.9,2-2v-4h-2V19z"/><polygon points="20.01,8.01 18.59,9.41 20.17,11 8,11 8,13 20.17,13 18.59,14.59 20.01,15.99 24,12"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#warning-->
+      <g id="warning"><path d="M0 0h24v24H0z" fill="none"/><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#timelapse-->
+      <g id="timelapse"><path d="M0 0h24v24H0z" fill="none"/><path d="M16.24 7.76C15.07 6.59 13.54 6 12 6v6l-4.24 4.24c2.34 2.34 6.14 2.34 8.49 0 2.34-2.34 2.34-6.14-.01-8.48zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#mark_chat_read-->
+      <g id="markChatRead"><path d="M12,18l-6,0l-4,4V4c0-1.1,0.9-2,2-2h16c1.1,0,2,0.9,2,2v7l-2,0V4H4v12l8,0V18z M23,14.34l-1.41-1.41l-4.24,4.24l-2.12-2.12 l-1.41,1.41L17.34,20L23,14.34z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#message-->
+      <g id="message"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#launch-->
+      <g id="launch"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#filter-->
+      <g id="filter"><path d="M0,0h24 M24,24H0" fill="none"/><path d="M4.25,5.61C6.27,8.2,10,13,10,13v6c0,0.55,0.45,1,1,1h2c0.55,0,1-0.45,1-1v-6c0,0,3.72-4.8,5.74-7.39 C20.25,4.95,19.78,4,18.95,4H5.04C4.21,4,3.74,4.95,4.25,5.61z"/><path d="M0,0h24v24H0V0z" fill="none"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#arrow_drop_down-->
+      <g id="arrowDropDown"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 10l5 5 5-5z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#arrow_drop_up-->
+      <g id="arrowDropUp"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 14l5-5 5 5z"/></g>
+      <!-- This is just a placeholder, i.e. an empty icon that has the same size as a normal icon. -->
+      <g id="placeholder"><path d="M0 0h24v24H0z" fill="none"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#insert_photo-->
+      <g id="insert-photo"><path d="M0 0h24v24H0z" fill="none"/><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#download-->
+      <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/#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>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
deleted file mode 100644
index 0cb628c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
+++ /dev/null
@@ -1,100 +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 {GrAnnotation} from '../../diff/gr-diff-highlight/gr-annotation';
-import {GrStyleObject} from '../../plugins/gr-styles-api/gr-styles-api';
-import {GrDiffLine} from '../../diff/gr-diff/gr-diff-line';
-
-/**
- * Used to create a context for GrAnnotationActionsInterface.
- *
- * @param contentEl The DIV.contentText element of the line
- * content to apply the annotation to using annotateRange.
- * @param lineNumberEl The TD element of the line number to
- * apply the annotation to using annotateLineNumber.
- * @param line The line object.
- * @param path The file path (eg: /COMMIT_MSG').
- * @param changeNum The Gerrit change number.
- * @param patchNum The Gerrit patch number.
- */
-export class GrAnnotationActionsContext {
-  private _contentEl: HTMLElement;
-
-  private _lineNumberEl: HTMLElement;
-
-  line: GrDiffLine;
-
-  path: string;
-
-  changeNum: number;
-
-  constructor(
-    contentEl: HTMLElement,
-    lineNumberEl: HTMLElement,
-    line: GrDiffLine,
-    path: string,
-    changeNum: string | number
-  ) {
-    this._contentEl = contentEl;
-    this._lineNumberEl = lineNumberEl;
-
-    this.line = line;
-    this.path = path;
-    this.changeNum = Number(changeNum);
-    if (isNaN(this.changeNum)) {
-      console.error(
-        `GrAnnotationActionsContext: Invalid changeNum: ${changeNum}`
-      );
-    }
-  }
-
-  /**
-   * Method to add annotations to a content line.
-   *
-   * @param offset The char offset where the update starts.
-   * @param length The number of chars that the update covers.
-   * @param styleObject The style object for the range.
-   * @param side The side of the update. ('left' or 'right')
-   */
-  annotateRange(
-    offset: number,
-    length: number,
-    styleObject: GrStyleObject,
-    side: string
-  ) {
-    if (this._contentEl?.getAttribute('data-side') === side) {
-      GrAnnotation.annotateElement(
-        this._contentEl,
-        offset,
-        length,
-        styleObject.getClassName(this._contentEl)
-      );
-    }
-  }
-
-  /**
-   * Method to add a CSS class to the line number TD element.
-   *
-   * @param styleObject The style object for the range.
-   * @param side The side of the update. ('left' or 'right')
-   */
-  annotateLineNumber(styleObject: GrStyleObject, side: string) {
-    if (this._lineNumberEl?.classList.contains(side)) {
-      styleObject.apply(this._lineNumberEl);
-    }
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.js
deleted file mode 100644
index b46a3b0..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.js
+++ /dev/null
@@ -1,87 +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-js-api-interface.js';
-import {GrAnnotation} from '../../diff/gr-diff-highlight/gr-annotation.js';
-import {GrAnnotationActionsContext} from './gr-annotation-actions-context.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-annotation-actions-context tests', () => {
-  let instance;
-
-  let el;
-  let lineNumberEl;
-  let plugin;
-
-  setup(() => {
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-
-    const str = 'lorem ipsum blah blah';
-    const line = {text: str};
-    el = document.createElement('div');
-    el.textContent = str;
-    el.setAttribute('data-side', 'right');
-    lineNumberEl = document.createElement('td');
-    lineNumberEl.classList.add('right');
-    document.body.appendChild(el);
-    instance = new GrAnnotationActionsContext(
-        el, lineNumberEl, line, 'dummy/path', '123', '1');
-  });
-
-  teardown(() => {
-    el.remove();
-  });
-
-  test('test annotateRange', () => {
-    const annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
-    const start = 0;
-    const end = 100;
-    const cssStyleObject = plugin.styles().css('background-color: #000000');
-
-    // Assert annotateElement is not called when side is different.
-    instance.annotateRange(start, end, cssStyleObject, 'left');
-    assert.equal(annotateElementSpy.callCount, 0);
-
-    // Assert annotateElement is called once when side is the same.
-    instance.annotateRange(start, end, cssStyleObject, 'right');
-    assert.equal(annotateElementSpy.callCount, 1);
-    const args = annotateElementSpy.getCalls()[0].args;
-    assert.equal(args[0], el);
-    assert.equal(args[1], start);
-    assert.equal(args[2], end);
-    assert.equal(args[3], cssStyleObject.getClassName(el));
-  });
-
-  test('test annotateLineNumber', () => {
-    const cssStyleObject = plugin.styles().css('background-color: #000000');
-
-    const className = cssStyleObject.getClassName(lineNumberEl);
-
-    // Assert that css class is *not* applied when side is different.
-    instance.annotateLineNumber(cssStyleObject, 'left');
-    assert.isFalse(lineNumberEl.classList.contains(className));
-
-    // Assert that css class is applied when side is the same.
-    instance.annotateLineNumber(cssStyleObject, 'right');
-    assert.isTrue(lineNumberEl.classList.contains(className));
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
index e069f8b..3f75970 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
@@ -14,88 +14,33 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GrAnnotationActionsContext} from './gr-annotation-actions-context';
-import {GrDiffLine} from '../../diff/gr-diff/gr-diff-line';
-import {
-  CoverageRange,
-  DiffLayer,
-  DiffLayerListener,
-} from '../../../types/types';
+import {DiffLayer, DiffLayerListener} from '../../../types/types';
 import {Side} from '../../../constants/constants';
-import {PluginApi} from '../../plugins/gr-plugin-types';
-import {ChangeInfo, NumericChangeId} from '../../../types/common';
+import {EventType, PluginApi} from '../../../api/plugin';
+import {appContext} from '../../../services/app-context';
+import {AnnotationPluginApi, CoverageProvider} from '../../../api/annotation';
 
-type AddLayerFunc = (ctx: GrAnnotationActionsContext) => void;
-
-type NotifyFunc = (
-  path: string,
-  start: number,
-  end: number,
-  side: Side
-) => void;
-
-export type CoverageProvider = (
-  changeNum: NumericChangeId,
-  path: string,
-  basePatchNum?: number,
-  patchNum?: number,
-  change?: ChangeInfo
-) => Promise<Array<CoverageRange>>;
-
-export class GrAnnotationActionsInterface {
-  // Collect all annotation layers instantiated by getLayer. Will be used when
-  // notifying their listeners in the notify function.
+export class GrAnnotationActionsInterface implements AnnotationPluginApi {
+  /**
+   * Collect all annotation layers instantiated by createLayer. This is only
+   * used for being able to look up the appropriate layer when notify() is
+   * being called by plugins.
+   */
   private annotationLayers: AnnotationLayer[] = [];
 
-  private coverageProvider: CoverageProvider | null = null;
+  private coverageProvider?: CoverageProvider;
 
-  // Default impl is a no-op.
-  private addLayerFunc: AddLayerFunc = () => {};
+  private readonly reporting = appContext.reportingService;
 
   constructor(private readonly plugin: PluginApi) {
-    // Return this instance when there is an annotatediff event.
-    plugin.on('annotatediff', this);
+    this.reporting.trackApi(this.plugin, 'annotation', 'constructor');
+    plugin.on(EventType.ANNOTATE_DIFF, this);
   }
 
-  /**
-   * Register a function to call to apply annotations. Plugins should use
-   * GrAnnotationActionsContext.annotateRange and
-   * GrAnnotationActionsContext.annotateLineNumber to apply a CSS class to the
-   * line content or the line number.
-   *
-   * @param addLayerFunc The function
-   * that will be called when the AnnotationLayer is ready to annotate.
-   */
-  addLayer(addLayerFunc: AddLayerFunc) {
-    this.addLayerFunc = addLayerFunc;
-    return this;
-  }
-
-  /**
-   * The specified function will be called with a notify function for the plugin
-   * to call when it has all required data for annotation. Optional.
-   *
-   * @param notifyFunc See doc of the notify function below to see what it does.
-   */
-  addNotifier(notifyFunc: (n: NotifyFunc) => void) {
-    notifyFunc(
-      (path: string, startRange: number, endRange: number, side: Side) =>
-        this.notify(path, startRange, endRange, side)
-    );
-    return this;
-  }
-
-  /**
-   * The specified function will be called when a gr-diff component is built,
-   * and feeds the returned coverage data into the diff. Optional.
-   *
-   * Be sure to call this only once and only from one plugin. Multiple coverage
-   * providers are not supported. A second call will just overwrite the
-   * provider of the first call.
-   */
   setCoverageProvider(
     coverageProvider: CoverageProvider
   ): GrAnnotationActionsInterface {
+    this.reporting.trackApi(this.plugin, 'annotation', 'setCoverageProvider');
     if (this.coverageProvider) {
       console.warn('Overwriting an existing coverage provider.');
     }
@@ -111,65 +56,8 @@
     return this.coverageProvider;
   }
 
-  /**
-   * Returns a checkbox HTMLElement that can be used to toggle annotations
-   * on/off. The checkbox will be initially disabled. Plugins should enable it
-   * when data is ready and should add a click handler to toggle CSS on/off.
-   *
-   * Note1: Calling this method from multiple plugins will only work for the
-   * 1st call. It will print an error message for all subsequent calls
-   * and will not invoke their onAttached functions.
-   * Note2: This method will be deprecated and eventually removed when
-   * https://bugs.chromium.org/p/gerrit/issues/detail?id=8077 is
-   * implemented.
-   *
-   * @param checkboxLabel Will be used as the label for the checkbox.
-   * Optional. "Enable" is used if this is not specified.
-   * @param onAttached The function that will be called
-   * when the checkbox is attached to the page.
-   */
-  enableToggleCheckbox(
-    checkboxLabel: string,
-    onAttached: (checkboxEl: Element | null) => void
-  ) {
-    this.plugin.hook('annotation-toggler').onAttached(element => {
-      if (!element.content) {
-        console.error('plugin endpoint without content.');
-        return;
-      }
-      if (!element.content.hidden) {
-        console.error(
-          element.content.id + ' is already enabled. Cannot re-enable.'
-        );
-        return;
-      }
-      element.content.removeAttribute('hidden');
-
-      const label = element.content.querySelector('#annotation-label');
-      if (label) {
-        if (checkboxLabel) {
-          label.textContent = checkboxLabel;
-        } else {
-          label.textContent = 'Enable';
-        }
-      }
-      const checkbox = element.content.querySelector('#annotation-checkbox');
-      onAttached(checkbox);
-    });
-    return this;
-  }
-
-  /**
-   * The notify function will call the listeners of all required annotation
-   * layers. Intended to be called by the plugin when all required data for
-   * annotation is available.
-   *
-   * @param path The file path whose listeners should be notified.
-   * @param start The line where the update starts.
-   * @param end The line where the update ends.
-   * @param side The side of the update ('left' or 'right').
-   */
   notify(path: string, start: number, end: number, side: Side) {
+    this.reporting.trackApi(this.plugin, 'annotation', 'notify');
     for (const annotationLayer of this.annotationLayers) {
       // Notify only the annotation layer that is associated with the specified
       // path.
@@ -180,24 +68,20 @@
   }
 
   /**
-   * Should be called to register annotation layers by the framework. Not
-   * intended to be called by plugins.
+   * Factory method called by Gerrit for creating a DiffLayer for each diff that
+   * is rendered.
    *
-   * Don't forget to dispose layer.
-   *
-   * @param path The file path (eg: /COMMIT_MSG').
-   * @param changeNum The Gerrit change number.
+   * Don't forget to also call disposeLayer().
    */
-  getLayer(path: string, changeNum: number) {
-    const annotationLayer = new AnnotationLayer(
-      path,
-      changeNum,
-      this.addLayerFunc
-    );
+  createLayer(path: string) {
+    const annotationLayer = new AnnotationLayer(path);
     this.annotationLayers.push(annotationLayer);
     return annotationLayer;
   }
 
+  /**
+   * Called by Gerrit for each diff renderer that had called createLayer().
+   */
   disposeLayer(path: string) {
     this.annotationLayers = this.annotationLayers.filter(
       annotationLayer => annotationLayer.path !== path
@@ -205,6 +89,10 @@
   }
 }
 
+/**
+ * An AnnotationLayer exists for each file that is being rendered. This class is
+ * not exposed to plugins, but being used by Gerrit's diff rendering.
+ */
 export class AnnotationLayer implements DiffLayer {
   private listeners: DiffLayerListener[] = [];
 
@@ -212,15 +100,8 @@
    * Used to create an instance of the Annotation Layer interface.
    *
    * @param path The file path (eg: /COMMIT_MSG').
-   * @param changeNum The Gerrit change number.
-   * @param addLayerFunc The function
-   * that will be called when the AnnotationLayer is ready to annotate.
    */
-  constructor(
-    readonly path: string,
-    private readonly changeNum: number,
-    private readonly addLayerFunc: AddLayerFunc
-  ) {
+  constructor(readonly path: string) {
     this.listeners = [];
   }
 
@@ -240,32 +121,12 @@
     this.listeners = this.listeners.filter(f => f !== listener);
   }
 
-  /**
-   * Layer method to add annotations to a line.
-   *
-   * @param contentEl The DIV.contentText element of the line
-   * content to apply the annotation to using annotateRange.
-   * @param lineNumberEl The TD element of the line number to
-   * apply the annotation to using annotateLineNumber.
-   * @param line The line object.
-   */
-  annotate(
-    contentEl: HTMLElement,
-    lineNumberEl: HTMLElement,
-    line: GrDiffLine
-  ) {
-    const annotationActionsContext = new GrAnnotationActionsContext(
-      contentEl,
-      lineNumberEl,
-      line,
-      this.path,
-      this.changeNum
-    );
-    this.addLayerFunc(annotationActionsContext);
-  }
+  annotate() {}
 
   /**
-   * Notify Layer listeners of changes to annotations.
+   * Notify layer listeners (which typically is just Gerrit's diff renderer) of
+   * changes to annotations after the diff rendering had already completed. This
+   * is indirectly called by plugins using the AnnotationPluginApi.notify().
    *
    * @param start The line where the update starts.
    * @param end The line where the update ends.
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
index 8b3f501..996edf3 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
@@ -18,16 +18,6 @@
 import '../../../test/common-test-setup-karma.js';
 import '../../change/gr-change-actions/gr-change-actions.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-  <span hidden id="annotation-span">
-    <label for="annotation-checkbox" id="annotation-label"></label>
-    <iron-input type="checkbox" disabled>
-      <input is="iron-input" type="checkbox" id="annotation-checkbox" disabled>
-    </iron-input>
-  </span>
-`);
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
@@ -46,53 +36,21 @@
     annotationActions = null;
   });
 
-  test('add/get layer', () => {
-    const str = 'lorem ipsum blah blah';
-    const line = {text: str};
-    const el = document.createElement('div');
-    el.textContent = str;
-    const changeNum = 1234;
-    let testLayerFuncCalled = false;
-
-    const testLayerFunc = context => {
-      testLayerFuncCalled = true;
-      assert.equal(context.line, line);
-      assert.equal(context.changeNum, changeNum);
-    };
-    annotationActions.addLayer(testLayerFunc);
-
-    const annotationLayer = annotationActions.getLayer(
-        '/dummy/path', changeNum);
-
-    const lineNumberEl = document.createElement('td');
-    annotationLayer.annotate(el, lineNumberEl, line);
-    assert.isTrue(testLayerFuncCalled);
-  });
-
   test('add notifier', () => {
     const path1 = '/dummy/path1';
     const path2 = '/dummy/path2';
-    const annotationLayer1 = annotationActions.getLayer(path1, 1);
-    const annotationLayer2 = annotationActions.getLayer(path2, 1);
+    const annotationLayer1 = annotationActions.createLayer(path1, 1);
+    const annotationLayer2 = annotationActions.createLayer(path2, 1);
     const layer1Spy = sinon.spy(annotationLayer1, 'notifyListeners');
     const layer2Spy = sinon.spy(annotationLayer2, 'notifyListeners');
 
-    let notify;
-    let notifyFuncCalled;
-    const notifyFunc = n => {
-      notifyFuncCalled = true;
-      notify = n;
-    };
-    annotationActions.addNotifier(notifyFunc);
-    assert.isTrue(notifyFuncCalled);
-
     // Assert that no layers are invoked with a different path.
-    notify('/dummy/path3', 0, 10, 'right');
+    annotationActions.notify('/dummy/path3', 0, 10, 'right');
     assert.isFalse(layer1Spy.called);
     assert.isFalse(layer2Spy.called);
 
     // Assert that only the 1st layer is invoked with path1.
-    notify(path1, 0, 10, 'right');
+    annotationActions.notify(path1, 0, 10, 'right');
     assert.isTrue(layer1Spy.called);
     assert.isFalse(layer2Spy.called);
 
@@ -101,52 +59,13 @@
     layer2Spy.resetHistory();
 
     // Assert that only the 2nd layer is invoked with path2.
-    notify(path2, 0, 20, 'left');
+    annotationActions.notify(path2, 0, 20, 'left');
     assert.isFalse(layer1Spy.called);
     assert.isTrue(layer2Spy.called);
   });
 
-  test('toggle checkbox', () => {
-    const fakeEl = {content: basicFixture.instantiate()};
-    const hookStub = {onAttached: sinon.stub()};
-    sinon.stub(plugin, 'hook').returns(hookStub);
-
-    let checkbox;
-    let onAttachedFuncCalled = false;
-    const onAttachedFunc = c => {
-      checkbox = c;
-      onAttachedFuncCalled = true;
-    };
-    annotationActions.enableToggleCheckbox('test label', onAttachedFunc);
-    const emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
-    emulateAttached();
-
-    // Assert that onAttachedFunc is called and HTML elements have the
-    // expected state.
-    assert.isTrue(onAttachedFuncCalled);
-    assert.equal(checkbox.id, 'annotation-checkbox');
-    assert.isTrue(checkbox.disabled);
-    assert.equal(document.getElementById('annotation-label').textContent,
-        'test label');
-    assert.isFalse(document.getElementById('annotation-span').hidden);
-
-    // Assert that error is shown if we try to enable checkbox again.
-    onAttachedFuncCalled = false;
-    annotationActions.enableToggleCheckbox('test label2', onAttachedFunc);
-    const errorStub = sinon.stub(
-        console, 'error').callsFake((msg, err) => undefined);
-    emulateAttached();
-    assert.isTrue(
-        errorStub.calledWith(
-            'annotation-span is already enabled. Cannot re-enable.'));
-    // Assert that onAttachedFunc is not called and the label has not changed.
-    assert.isFalse(onAttachedFuncCalled);
-    assert.equal(document.getElementById('annotation-label').textContent,
-        'test label');
-  });
-
   test('layer notify listeners', () => {
-    const annotationLayer = annotationActions.getLayer('/dummy/path', 1);
+    const annotationLayer = annotationActions.createLayer('/dummy/path', 1);
     let listenerCalledTimes = 0;
     const startRange = 10;
     const endRange = 20;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
index 8f743e9..db127f3 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
@@ -16,23 +16,13 @@
  */
 
 import {getBaseUrl} from '../../../utils/url-util';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {HttpMethod} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
 
 export const PRELOADED_PROTOCOL = 'preloaded:';
 export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
 
-let _restAPI: RestApiService | undefined;
-export function getRestAPI() {
-  if (!_restAPI) {
-    _restAPI = (document.createElement(
-      'gr-rest-api-interface'
-    ) as unknown) as RestApiService;
-  }
-  return _restAPI;
-}
-
 /**
  * Retrieves the name of the plugin base on the url.
  */
@@ -69,9 +59,9 @@
   }
 
   // Pathname should normally look like this:
-  // /plugins/PLUGINNAME/static/SCRIPTNAME.html
+  // /plugins/PLUGINNAME/static/SCRIPTNAME.js
   // Or, for app/samples:
-  // /plugins/PLUGINNAME.html
+  // /plugins/PLUGINNAME.js
   // TODO(taoalpha): guard with a regex
   return pathname.split('/')[2].split('.')[0];
 }
@@ -83,7 +73,7 @@
   opt_callback?: (response: unknown) => void,
   opt_payload?: RequestPayload
 ) {
-  return getRestAPI()
+  return appContext.restApiService
     .send(method, url, opt_payload)
     .then(response => {
       if (response.status < 200 || response.status >= 300) {
@@ -95,7 +85,7 @@
           }
         });
       } else {
-        return getRestAPI().getResponseObject(response);
+        return appContext.restApiService.getResponseObject(response);
       }
     })
     .then(response => {
@@ -105,9 +95,3 @@
       return response;
     });
 }
-
-// TEST only methods / properties
-
-export function testOnly_resetInternalState() {
-  _restAPI = undefined;
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.js
index 85c62cb..a09d887 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.js
@@ -34,18 +34,18 @@
     test('with random invalid url', () => {
       assert.equal(getPluginNameFromUrl('http://example.com'), null);
       assert.equal(
-          getPluginNameFromUrl('http://example.com/static/a.html'),
+          getPluginNameFromUrl('http://example.com/static/a.js'),
           null
       );
     });
 
     test('with valid urls', () => {
       assert.equal(
-          getPluginNameFromUrl('http://example.com/plugins/a.html'),
+          getPluginNameFromUrl('http://example.com/plugins/a.js'),
           'a'
       );
       assert.equal(
-          getPluginNameFromUrl('http://example.com/plugins/a/static/t.html'),
+          getPluginNameFromUrl('http://example.com/plugins/a/static/t.js'),
           'a'
       );
     });
@@ -56,7 +56,7 @@
 
     test('with gerrit-theme override', () => {
       assert.equal(
-          getPluginNameFromUrl('http://example.com/static/gerrit-theme.html'),
+          getPluginNameFromUrl('http://example.com/static/gerrit-theme.js'),
           'gerrit-theme'
       );
     });
@@ -64,7 +64,7 @@
     test('with ASSETS_PATH', () => {
       window.ASSETS_PATH = 'http://cdn.com/2';
       assert.equal(
-          getPluginNameFromUrl(`${window.ASSETS_PATH}/plugins/a.html`),
+          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 a493a2e..15d4680 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -14,51 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {
-  ActionType,
-  ActionPriority,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {JsApiService} from './gr-js-api-types';
-import {TargetElement} from '../../plugins/gr-plugin-types';
+import {PluginApi, TargetElement} from '../../../api/plugin';
 import {ActionInfo, RequireProperties} from '../../../types/common';
-
-interface Plugin {
-  getPluginName(): string;
-}
-
-export enum ChangeActions {
-  ABANDON = 'abandon',
-  DELETE = '/',
-  DELETE_EDIT = 'deleteEdit',
-  EDIT = 'edit',
-  FOLLOW_UP = 'followup',
-  IGNORE = 'ignore',
-  MOVE = 'move',
-  PRIVATE = 'private',
-  PRIVATE_DELETE = 'private.delete',
-  PUBLISH_EDIT = 'publishEdit',
-  REBASE = 'rebase',
-  REBASE_EDIT = 'rebaseEdit',
-  READY = 'ready',
-  RESTORE = 'restore',
-  REVERT = 'revert',
-  REVERT_SUBMISSION = 'revert_submission',
-  REVIEWED = 'reviewed',
-  STOP_EDIT = 'stopEdit',
-  SUBMIT = 'submit',
-  UNIGNORE = 'unignore',
-  UNREVIEWED = 'unreviewed',
-  WIP = 'wip',
-}
-
-export enum RevisionActions {
-  CHERRYPICK = 'cherrypick',
-  REBASE = 'rebase',
-  SUBMIT = 'submit',
-  DOWNLOAD = 'download',
-}
-
-export type PrimaryActionKey = ChangeActions | RevisionActions;
+import {appContext} from '../../../services/app-context';
+import {
+  ActionPriority,
+  ActionType,
+  ChangeActions,
+  ChangeActionsPluginApi,
+  PrimaryActionKey,
+  RevisionActions,
+} from '../../../api/change-actions';
 
 export interface UIActionInfo extends RequireProperties<ActionInfo, 'label'> {
   __key: string;
@@ -93,8 +59,8 @@
   getActionDetails(actionName: string): ActionInfo | undefined;
 }
 
-export class GrChangeActionsInterface {
-  private _el?: GrChangeActionsElement;
+export class GrChangeActionsInterface implements ChangeActionsPluginApi {
+  private el?: GrChangeActionsElement;
 
   RevisionActions = RevisionActions;
 
@@ -102,7 +68,10 @@
 
   ActionType = ActionType;
 
-  constructor(public plugin: Plugin, el?: GrChangeActionsElement) {
+  private readonly reporting = appContext.reportingService;
+
+  constructor(public plugin: PluginApi, el?: GrChangeActionsElement) {
+    this.reporting.trackApi(this.plugin, 'actions', 'constructor');
     this.setEl(el);
   }
 
@@ -114,7 +83,7 @@
       console.warn('changeActions() is not ready');
       return;
     }
-    this._el = el;
+    this.el = el;
   }
 
   /**
@@ -122,20 +91,19 @@
    * element and retrieve if the interface was created before element.
    */
   private ensureEl(): GrChangeActionsElement {
-    if (!this._el) {
-      const sharedApiElement = (document.createElement(
-        'gr-js-api-interface'
-      ) as unknown) as JsApiService;
+    if (!this.el) {
+      const sharedApiElement = appContext.jsApiService;
       this.setEl(
         (sharedApiElement.getElement(
           TargetElement.CHANGE_ACTIONS
         ) as unknown) as GrChangeActionsElement
       );
     }
-    return this._el!;
+    return this.el!;
   }
 
   addPrimaryActionKey(key: PrimaryActionKey) {
+    this.reporting.trackApi(this.plugin, 'actions', 'addPrimaryActionKey');
     const el = this.ensureEl();
     if (el.primaryActionKeys.includes(key)) {
       return;
@@ -145,63 +113,77 @@
   }
 
   removePrimaryActionKey(key: string) {
+    this.reporting.trackApi(this.plugin, 'actions', 'removePrimaryActionKey');
     const el = this.ensureEl();
     el.primaryActionKeys = el.primaryActionKeys.filter(k => k !== key);
   }
 
   hideQuickApproveAction() {
+    this.reporting.trackApi(this.plugin, 'actions', 'hideQuickApproveAction');
     this.ensureEl().hideQuickApproveAction();
   }
 
   setActionOverflow(type: ActionType, key: string, overflow: boolean) {
+    this.reporting.trackApi(this.plugin, 'actions', 'setActionOverflow');
     // TODO(TS): remove return, unclear why it was written
     return this.ensureEl().setActionOverflow(type, key, overflow);
   }
 
   setActionPriority(type: ActionType, key: string, priority: ActionPriority) {
+    this.reporting.trackApi(this.plugin, 'actions', 'setActionPriority');
     // TODO(TS): remove return, unclear why it was written
     return this.ensureEl().setActionPriority(type, key, priority);
   }
 
   setActionHidden(type: ActionType, key: string, hidden: boolean) {
+    this.reporting.trackApi(this.plugin, 'actions', 'setActionHidden');
     // TODO(TS): remove return, unclear why it was written
     return this.ensureEl().setActionHidden(type, key, hidden);
   }
 
   add(type: ActionType, label: string): string {
+    this.reporting.trackApi(this.plugin, 'actions', 'add');
     return this.ensureEl().addActionButton(type, label);
   }
 
   remove(key: string) {
+    this.reporting.trackApi(this.plugin, 'actions', 'remove');
     // TODO(TS): remove return, unclear why it was written
     return this.ensureEl().removeActionButton(key);
   }
 
   addTapListener(key: string, handler: EventListenerOrEventListenerObject) {
+    this.reporting.trackApi(this.plugin, 'actions', 'addTapListener');
     this.ensureEl().addEventListener(key + '-tap', handler);
   }
 
   removeTapListener(key: string, handler: EventListenerOrEventListenerObject) {
+    this.reporting.trackApi(this.plugin, 'actions', 'removeTapListener');
     this.ensureEl().removeEventListener(key + '-tap', handler);
   }
 
   setLabel(key: string, text: string) {
+    this.reporting.trackApi(this.plugin, 'actions', 'setLabel');
     this.ensureEl().setActionButtonProp(key, 'label', text);
   }
 
   setTitle(key: string, text: string) {
+    this.reporting.trackApi(this.plugin, 'actions', 'setTitle');
     this.ensureEl().setActionButtonProp(key, 'title', text);
   }
 
   setEnabled(key: string, enabled: boolean) {
+    this.reporting.trackApi(this.plugin, 'actions', 'setEnabled');
     this.ensureEl().setActionButtonProp(key, 'enabled', enabled);
   }
 
   setIcon(key: string, icon: string) {
+    this.reporting.trackApi(this.plugin, 'actions', 'setIcon');
     this.ensureEl().setActionButtonProp(key, 'icon', icon);
   }
 
   getActionDetails(action: string) {
+    this.reporting.trackApi(this.plugin, 'actions', 'getActionDetails');
     const el = this.ensureEl();
     return (
       el.getActionDetails(action) ||
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
index 7069304..fc5a4aa 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
@@ -15,30 +15,30 @@
  * limitations under the License.
  */
 
-import {GrReplyDialog} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {PluginApi, TargetElement} from '../../plugins/gr-plugin-types';
+import {GrReplyDialog} from '../../../services/gr-rest-api/gr-rest-api';
+import {PluginApi, TargetElement} from '../../../api/plugin';
 import {JsApiService} from './gr-js-api-types';
-
-// TODO(TS): maybe move interfaces\types to other files when convertion complete
-interface LabelsChangedDetail {
-  name: string;
-  value: string;
-}
-interface ValueChangedDetail {
-  value: string;
-}
-
-type ReplyChangedCallback = (text: string) => void;
-type LabelsChangedCallback = (detail: LabelsChangedDetail) => void;
+import {
+  ChangeReplyPluginApi,
+  LabelsChangedCallback,
+  LabelsChangedDetail,
+  ReplyChangedCallback,
+  ValueChangedDetail,
+} from '../../../api/change-reply';
+import {appContext} from '../../../services/app-context';
 
 /**
  * GrChangeReplyInterface, provides a set of handy methods on reply dialog.
  */
-export class GrChangeReplyInterface {
+export class GrChangeReplyInterface implements ChangeReplyPluginApi {
+  private readonly reporting = appContext.reportingService;
+
   constructor(
     readonly plugin: PluginApi,
     readonly sharedApiElement: JsApiService
-  ) {}
+  ) {
+    this.reporting.trackApi(this.plugin, 'reply', 'constructor');
+  }
 
   get _el(): GrReplyDialog {
     return (this.sharedApiElement.getElement(
@@ -46,19 +46,18 @@
     ) as unknown) as GrReplyDialog;
   }
 
-  getLabelValue(label: string) {
+  getLabelValue(label: string): string {
+    this.reporting.trackApi(this.plugin, 'reply', 'getLabelValue');
     return this._el.getLabelValue(label);
   }
 
   setLabelValue(label: string, value: string) {
+    this.reporting.trackApi(this.plugin, 'reply', 'setLabelValue');
     this._el.setLabelValue(label, value);
   }
 
-  send(includeComments?: boolean) {
-    this._el.send(includeComments);
-  }
-
   addReplyTextChangedCallback(handler: ReplyChangedCallback) {
+    this.reporting.trackApi(this.plugin, 'reply', 'addReplyTextChangedCb');
     const hookApi = this.plugin.hook('reply-text');
     const registeredHandler = (e: Event) => {
       const ce = e as CustomEvent<ValueChangedDetail>;
@@ -79,6 +78,7 @@
   }
 
   addLabelValuesChangedCallback(handler: LabelsChangedCallback) {
+    this.reporting.trackApi(this.plugin, 'reply', 'addLabelValuesChangedCb');
     const hookApi = this.plugin.hook('reply-label-scores');
     const registeredHandler = (e: Event) => {
       const ce = e as CustomEvent<LabelsChangedDetail>;
@@ -100,6 +100,7 @@
   }
 
   showMessage(message: string) {
-    return this._el.setPluginMessage(message);
+    this.reporting.trackApi(this.plugin, 'reply', 'showMessage');
+    this._el.setPluginMessage(message);
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
index 8f41b39..2324588 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import '../../change/gr-reply-dialog/gr-reply-dialog.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-reply-dialog');
 
@@ -30,10 +31,7 @@
   let plugin;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getAccount() { return Promise.resolve(null); },
-    });
+    stubRestApi('getAccount').returns(Promise.resolve(null));
   });
 
   suite('early init', () => {
@@ -57,10 +55,6 @@
       assert.isTrue(
           element.setLabelValue.calledWithExactly('My-Label', '+1337'));
 
-      sinon.stub(element, 'send');
-      changeReply.send(false);
-      assert.isTrue(element.send.calledWithExactly(false));
-
       sinon.stub(element, 'setPluginMessage');
       changeReply.showMessage('foobar');
       assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
@@ -88,10 +82,6 @@
       assert.isTrue(
           element.setLabelValue.calledWithExactly('My-Label', '+1337'));
 
-      sinon.stub(element, 'send');
-      changeReply.send(false);
-      assert.isTrue(element.send.calledWithExactly(false));
-
       sinon.stub(element, 'setPluginMessage');
       changeReply.showMessage('foobar');
       assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
index 37ac354..7afdd20 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
@@ -19,14 +19,10 @@
  * This defines the Gerrit instance. All methods directly attached to Gerrit
  * should be defined or linked here.
  */
-import {
-  getPluginLoader,
-  PluginOptionMap,
-  PluginLoader,
-} from './gr-plugin-loader';
-import {getRestAPI, send} from './gr-api-utils';
+import {getPluginLoader, PluginOptionMap} from './gr-plugin-loader';
+import {send} from './gr-api-utils';
 import {appContext} from '../../../services/app-context';
-import {PluginApi} from '../../plugins/gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
 import {HttpMethod} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
 import {
@@ -34,15 +30,8 @@
   EventEmitterService,
 } from '../../../services/gr-event-interface/gr-event-interface';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {getRootElement} from '../../../scripts/rootElement';
-import {GrPluginEndpoints} from './gr-plugin-endpoints';
-import {rangesEqual} from '../../diff/gr-diff/gr-diff-utils';
-import {SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
-import {CoverageType} from '../../../types/types';
-import {RevisionInfo} from '../revision-info/revision-info';
 
 export interface GerritGlobal extends EventEmitterService {
-  flushPreinstalls?(): void;
   css(rule: string): string;
   install(
     callback: (plugin: PluginApi) => void,
@@ -75,31 +64,10 @@
   // exposed methods
   Nav: typeof GerritNav;
   Auth: typeof appContext.authService;
-  getRootElement: typeof getRootElement;
-  _pluginLoader: PluginLoader;
-  _endpoints: GrPluginEndpoints;
-  slotToContent(slot: unknown): unknown;
-  rangesEqual: typeof rangesEqual;
-  SUGGESTIONS_PROVIDERS_USERS_TYPES: typeof SUGGESTIONS_PROVIDERS_USERS_TYPES;
-  CoverageType: typeof CoverageType;
-  RevisionInfo: typeof RevisionInfo;
 }
 
-/**
- * Trigger the preinstalls for bundled plugins.
- * This needs to happen before Gerrit as plugin bundle overrides the Gerrit.
- */
-function flushPreinstalls() {
-  const Gerrit = window.Gerrit;
-  if (Gerrit?.flushPreinstalls) {
-    Gerrit.flushPreinstalls();
-  }
-}
-export const _testOnly_flushPreinstalls = flushPreinstalls;
-
 export function initGerritPluginApi() {
   window.Gerrit = window.Gerrit || {};
-  flushPreinstalls();
   initGerritPluginsMethods(window.Gerrit as GerritGlobal);
   // Preloaded plugins should be installed after Gerrit.install() is set,
   // since plugin preloader substitutes Gerrit.install() temporarily.
@@ -118,7 +86,7 @@
   callback?: (response: Response) => void
 ) {
   console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
-  return getRestAPI()
+  return appContext.restApiService
     .send(HttpMethod.DELETE, url)
     .then(response => {
       if (response.status !== 204) {
@@ -135,12 +103,17 @@
     });
 }
 
+const fakeApi = {
+  getPluginName: () => 'global',
+};
+
 function initGerritPluginsMethods(globalGerritObj: GerritGlobal) {
   /**
    * @deprecated Use plugin.styles().css(rulesStr) instead. Please, consult
    * the documentation how to replace it accordingly.
    */
   globalGerritObj.css = (rulesStr: string) => {
+    appContext.reportingService.trackApi(fakeApi, 'global', 'css');
     console.warn(
       'Gerrit.css(rulesStr) is deprecated!',
       'Use plugin.styles().css(rulesStr)'
@@ -164,17 +137,19 @@
   };
 
   globalGerritObj.getLoggedIn = () => {
+    appContext.reportingService.trackApi(fakeApi, 'global', 'getLoggedIn');
     console.warn(
       'Gerrit.getLoggedIn() is deprecated! ' +
         'Use plugin.restApi().getLoggedIn()'
     );
-    return document.createElement('gr-rest-api-interface').getLoggedIn();
+    return appContext.restApiService.getLoggedIn();
   };
 
   globalGerritObj.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);
   };
@@ -184,6 +159,7 @@
     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);
   };
@@ -193,6 +169,7 @@
     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);
   };
@@ -201,32 +178,51 @@
     url: string,
     callback?: (response: Response) => void
   ) => {
+    appContext.reportingService.trackApi(fakeApi, 'global', 'delete');
     deprecatedDelete(url, callback);
   };
 
   globalGerritObj.awaitPluginsLoaded = () => {
+    appContext.reportingService.trackApi(
+      fakeApi,
+      'global',
+      'awaitPluginsLoaded'
+    );
     return getPluginLoader().awaitPluginsLoaded();
   };
 
   // TODO(taoalpha): consider removing these proxy methods
   // and using getPluginLoader() directly
-  globalGerritObj._loadPlugins = (plugins, opt_option) => {
-    getPluginLoader().loadPlugins(plugins, opt_option);
+  globalGerritObj._loadPlugins = plugins => {
+    appContext.reportingService.trackApi(fakeApi, 'global', '_loadPlugins');
+    getPluginLoader().loadPlugins(plugins);
   };
 
   globalGerritObj._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 => {
+    appContext.reportingService.trackApi(fakeApi, 'global', '_isPluginEnabled');
     return getPluginLoader().isPluginEnabled(pathOrUrl);
   };
 
   globalGerritObj._isPluginLoaded = pathOrUrl => {
+    appContext.reportingService.trackApi(fakeApi, 'global', '_isPluginLoaded');
     return getPluginLoader().isPluginLoaded(pathOrUrl);
   };
 
@@ -250,27 +246,49 @@
    *   });
    * });
    *
-   * // Listen on your-special-event from pluignB
+   * // Listen on your-special-event from pluginB
    * Gerrit.install(pluginB => {
    *   Gerrit.on("your-special-event", ({plugin}) => {
    *     // do something, plugin is pluginA
    *   });
    * });
    */
-  globalGerritObj.addListener = (eventName: string, cb: EventCallback) =>
-    eventEmitter.addListener(eventName, cb);
-  globalGerritObj.dispatch = (eventName: string, detail: any) =>
-    eventEmitter.dispatch(eventName, detail);
-  globalGerritObj.emit = (eventName: string, detail: any) =>
-    eventEmitter.emit(eventName, detail);
-  globalGerritObj.off = (eventName: string, cb: EventCallback) =>
-    eventEmitter.off(eventName, cb);
-  globalGerritObj.on = (eventName: string, cb: EventCallback) =>
-    eventEmitter.on(eventName, cb);
-  globalGerritObj.once = (eventName: string, cb: EventCallback) =>
-    eventEmitter.once(eventName, cb);
-  globalGerritObj.removeAllListeners = (eventName: string) =>
-    eventEmitter.removeAllListeners(eventName);
-  globalGerritObj.removeListener = (eventName: string, cb: EventCallback) =>
-    eventEmitter.removeListener(eventName, cb);
+  globalGerritObj.addListener = (eventName: string, cb: EventCallback) => {
+    appContext.reportingService.trackApi(fakeApi, 'global', 'addListener');
+    return eventEmitter.addListener(eventName, cb);
+  };
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  globalGerritObj.dispatch = (eventName: string, detail: any) => {
+    appContext.reportingService.trackApi(fakeApi, 'global', 'dispatch');
+    return eventEmitter.dispatch(eventName, detail);
+  };
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  globalGerritObj.emit = (eventName: string, detail: any) => {
+    appContext.reportingService.trackApi(fakeApi, 'global', 'emit');
+    return eventEmitter.emit(eventName, detail);
+  };
+  globalGerritObj.off = (eventName: string, cb: EventCallback) => {
+    appContext.reportingService.trackApi(fakeApi, 'global', 'off');
+    return eventEmitter.off(eventName, cb);
+  };
+  globalGerritObj.on = (eventName: string, cb: EventCallback) => {
+    appContext.reportingService.trackApi(fakeApi, 'global', 'on');
+    return eventEmitter.on(eventName, cb);
+  };
+  globalGerritObj.once = (eventName: string, cb: EventCallback) => {
+    appContext.reportingService.trackApi(fakeApi, 'global', 'once');
+    return eventEmitter.once(eventName, cb);
+  };
+  globalGerritObj.removeAllListeners = (eventName: string) => {
+    appContext.reportingService.trackApi(
+      fakeApi,
+      'global',
+      'removeAllListeners'
+    );
+    return eventEmitter.removeAllListeners(eventName);
+  };
+  globalGerritObj.removeListener = (eventName: string, cb: EventCallback) => {
+    appContext.reportingService.trackApi(fakeApi, 'global', 'removeListener');
+    return 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 9312f1b..608e711 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
@@ -16,12 +16,11 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import './gr-js-api-interface.js';
 import {getPluginLoader} from './gr-plugin-loader.js';
 import {resetPlugins} from '../../../test/test-utils.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const basicFixture = fixtureFromElement('gr-js-api-interface');
+import {stubRestApi} from '../../../test/test-utils.js';
+import {appContext} from '../../../services/app-context.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
@@ -29,21 +28,13 @@
   let element;
 
   let clock;
-  let sendStub;
 
   setup(() => {
     clock = sinon.useFakeTimers();
 
-    sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
-    stub('gr-rest-api-interface', {
-      getAccount() {
-        return Promise.resolve({name: 'Judy Hopps'});
-      },
-      send(...args) {
-        return sendStub(...args);
-      },
-    });
-    element = basicFixture.instantiate();
+    stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
+    stubRestApi('send').returns(Promise.resolve({status: 200}));
+    element = appContext.jsApiService;
   });
 
   teardown(() => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 736fac9..95d2e7f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -14,36 +14,34 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {getPluginLoader} from './gr-plugin-loader';
-import {patchNumEquals} from '../../../utils/patch-set-util';
-import {customElement} from '@polymer/decorators';
+import {hasOwnProperty} from '../../../utils/common-util';
 import {
   ChangeInfo,
   LabelNameToValuesMap,
+  ReviewInput,
   RevisionInfo,
 } from '../../../types/common';
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
-import {GrAdminApi, MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
+import {GrAdminApi} from '../../plugins/gr-admin-api/gr-admin-api';
 import {
   JsApiService,
   EventCallback,
   ShowChangeDetail,
   ShowRevisionActionsDetail,
 } from './gr-js-api-types';
-import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
-import {DiffLayer, HighlightJS} from '../../../types/types';
-import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
+import {EventType, TargetElement} from '../../../api/plugin';
+import {DiffLayer, HighlightJS, ParsedChangeInfo} from '../../../types/types';
+import {appContext} from '../../../services/app-context';
+import {MenuLink} from '../../../api/admin';
 
 const elements: {[key: string]: HTMLElement} = {};
 const eventCallbacks: {[key: string]: EventCallback[]} = {};
 
-@customElement('gr-js-api-interface')
-export class GrJsApiInterface
-  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
-  implements JsApiService {
+export class GrJsApiInterface implements JsApiService {
+  private readonly reporting = appContext.reportingService;
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   handleEvent(type: EventType, detail: any) {
     getPluginLoader()
       .awaitPluginsLoaded()
@@ -98,7 +96,7 @@
       try {
         return callback(change, revision) === false;
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
       return false;
     });
@@ -119,7 +117,7 @@
       try {
         cb(detail.path);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
   }
@@ -150,7 +148,7 @@
 
     let revision;
     for (const rev of Object.values(change.revisions || {})) {
-      if (patchNumEquals(rev._number, patchNum)) {
+      if (rev._number === patchNum) {
         revision = rev;
         break;
       }
@@ -160,7 +158,7 @@
       try {
         cb(change, revision, info);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
   }
@@ -173,7 +171,7 @@
       try {
         cb(detail.revisionActions, detail.change);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
   }
@@ -183,7 +181,7 @@
       try {
         cb(change, msg);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
   }
@@ -194,7 +192,7 @@
       try {
         cb(detail.node);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
   }
@@ -204,7 +202,7 @@
       try {
         cb(detail.change);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
   }
@@ -214,7 +212,7 @@
       try {
         cb(detail.hljs);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
   }
@@ -224,7 +222,7 @@
       try {
         revertMsg = cb(change, revertMsg, origMsg) as string;
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
     return revertMsg;
@@ -243,21 +241,21 @@
           origMsg
         ) as string;
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
     return revertSubmissionMsg;
   }
 
-  getDiffLayers(path: string, changeNum: number) {
+  getDiffLayers(path: string) {
     const layers: DiffLayer[] = [];
     for (const cb of this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
       const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
       try {
-        const layer = annotationApi.getLayer(path, changeNum);
-        layers.push(layer);
+        const layer = annotationApi.createLayer(path);
+        if (layer) layers.push(layer);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
     return layers;
@@ -269,7 +267,7 @@
         const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
         annotationApi.disposeLayer(path);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
   }
@@ -304,25 +302,24 @@
     return links;
   }
 
-  getLabelValuesPostRevert(change?: ChangeInfo): LabelNameToValuesMap {
-    let labels: LabelNameToValuesMap = {};
+  getReviewPostRevert(change?: ChangeInfo): ReviewInput {
+    let review: ReviewInput = {};
     for (const cb of this._getEventCallbacks(EventType.POST_REVERT)) {
       try {
-        labels = cb(change);
+        const r = cb(change);
+        if (hasOwnProperty(r, 'labels')) {
+          review = r as ReviewInput;
+        } else {
+          review = {labels: r as LabelNameToValuesMap};
+        }
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
-    return labels;
+    return review;
   }
 
   _getEventCallbacks(type: EventType) {
     return eventCallbacks[type] || [];
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-js-api-interface': JsApiService & Element;
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
index b9a6ff4..a05e7e1 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import './gr-js-api-interface-element';
 import './gr-public-js-api';
 import './gr-gerrit';
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
index b5c4e48..a9bb8ae 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
@@ -18,23 +18,22 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-js-api-interface.js';
 import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface.js';
-import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api.js';
-import {EventType} from '../../plugins/gr-plugin-types.js';
+import {EventType} from '../../../api/plugin.js';
 import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
 import {getPluginLoader} from './gr-plugin-loader.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-js-api-interface');
+import sinon from 'sinon/pkg/sinon-esm';
+import {stubRestApi} from '../../../test/test-utils.js';
+import {appContext} from '../../../services/app-context.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
-suite('gr-js-api-interface tests', () => {
+suite('GrJsApiInterface tests', () => {
   let element;
   let plugin;
   let errorStub;
 
-  let getResponseObjectStub;
   let sendStub;
   let clock;
 
@@ -45,19 +44,10 @@
   setup(() => {
     clock = sinon.useFakeTimers();
 
-    getResponseObjectStub = sinon.stub().returns(Promise.resolve());
-    sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
-    stub('gr-rest-api-interface', {
-      getAccount() {
-        return Promise.resolve({name: 'Judy Hopps'});
-      },
-      getResponseObject: getResponseObjectStub,
-      send(...args) {
-        return sendStub(...args);
-      },
-    });
-    element = basicFixture.instantiate();
-    errorStub = sinon.stub(console, 'error');
+    stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
+    sendStub = stubRestApi('send').returns(Promise.resolve({status: 200}));
+    element = appContext.jsApiService;
+    errorStub = sinon.stub(element.reporting, 'error');
     pluginApi.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     getPluginLoader().loadPlugins([]);
@@ -113,76 +103,6 @@
     });
   });
 
-  test('get', () => {
-    const response = {foo: 'foo'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return plugin.get('/url', r => {
-      assert.isTrue(sendStub.calledWith(
-          'GET', 'http://test.com/plugins/testplugin/url'));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('get using Promise', () => {
-    const response = {foo: 'foo'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return plugin.get('/url', r => 'rubbish').then(r => {
-      assert.isTrue(sendStub.calledWith(
-          'GET', 'http://test.com/plugins/testplugin/url'));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('post', () => {
-    const payload = {foo: 'foo'};
-    const response = {bar: 'bar'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return plugin.post('/url', payload, r => {
-      assert.isTrue(sendStub.calledWith(
-          'POST', 'http://test.com/plugins/testplugin/url', payload));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('put', () => {
-    const payload = {foo: 'foo'};
-    const response = {bar: 'bar'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return plugin.put('/url', payload, r => {
-      assert.isTrue(sendStub.calledWith(
-          'PUT', 'http://test.com/plugins/testplugin/url', payload));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('delete works', () => {
-    const response = {status: 204};
-    sendStub.returns(Promise.resolve(response));
-    return plugin.delete('/url', r => {
-      assert.equal(sendStub.lastCall.args[0], 'DELETE');
-      assert.equal(
-          sendStub.lastCall.args[1],
-          'http://test.com/plugins/testplugin/url'
-      );
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('delete fails', () => {
-    sendStub.returns(Promise.resolve(
-        {status: 400, text() { return Promise.resolve('text'); }}));
-    return plugin.delete('/url', r => {
-      throw new Error('Should not resolve');
-    }).catch(err => {
-      assert.equal(sendStub.lastCall.args[0], 'DELETE');
-      assert.equal(
-          sendStub.lastCall.args[1],
-          'http://test.com/plugins/testplugin/url'
-      );
-      assert.equal('text', err.message);
-    });
-  });
-
   test('history event', async () => {
     let resolve;
     const promise = new Promise(r => resolve = r);
@@ -242,7 +162,7 @@
       revisions: {def: {_number: 2}, abc: {_number: 1}},
     };
     const spy = sinon.spy();
-    getPluginLoader().loadPlugins(['plugins/test.html']);
+    getPluginLoader().loadPlugins(['plugins/test.js']);
     plugin.on(EventType.SHOW_CHANGE, spy);
     element.handleEvent(EventType.SHOW_CHANGE,
         {change: testChange, patchNum: 1});
@@ -288,18 +208,33 @@
     assert.isTrue(errorStub.calledTwice);
   });
 
-  test('postrevert event', () => {
+  test('postrevert event labels', () => {
     function getLabels(c) {
       return {'Code-Review': 1};
     }
 
-    assert.deepEqual(element.getLabelValuesPostRevert(null), {});
+    assert.deepEqual(element.getReviewPostRevert(null), {});
     assert.equal(errorStub.callCount, 0);
 
     plugin.on(EventType.POST_REVERT, throwErrFn);
     plugin.on(EventType.POST_REVERT, getLabels);
     assert.deepEqual(
-        element.getLabelValuesPostRevert(null), {'Code-Review': 1});
+        element.getReviewPostRevert(null), {labels: {'Code-Review': 1}});
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('postrevert event review', () => {
+    function getReview(c) {
+      return {labels: {'Code-Review': 1}};
+    }
+
+    assert.deepEqual(element.getReviewPostRevert(null), {});
+    assert.equal(errorStub.callCount, 0);
+
+    plugin.on(EventType.POST_REVERT, throwErrFn);
+    plugin.on(EventType.POST_REVERT, getReview);
+    assert.deepEqual(
+        element.getReviewPostRevert(null), {labels: {'Code-Review': 1}});
     assert.isTrue(errorStub.calledOnce);
   });
 
@@ -404,6 +339,7 @@
 
   suite('popup', () => {
     test('popup(element) is deprecated', () => {
+      sinon.stub(console, 'error');
       plugin.popup(document.createElement('div'));
       assert.isTrue(console.error.calledOnce);
     });
@@ -416,7 +352,7 @@
             // eslint-disable-next-line no-invalid-this
             const grPopupInterface = this;
             assert.equal(grPopupInterface.plugin, plugin);
-            assert.equal(grPopupInterface._moduleName, 'some-name');
+            assert.equal(grPopupInterface.moduleName, 'some-name');
           });
       plugin.popup('some-name');
       assert.isTrue(openStub.calledOnce);
@@ -443,12 +379,5 @@
           'testplugin-screen-foo', 'some-module'));
     });
   });
-
-  suite('settingsScreen', () => {
-    test('plugin.settings() returns GrSettingsApi', () => {
-      assert.isOk(plugin.settings());
-      assert.isTrue(plugin.settings() instanceof GrSettingsApi);
-    });
-  });
 });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
index 6456370..9644ef3 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -14,11 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {ActionInfo, ChangeInfo, PatchSetNum} from '../../../types/common';
-import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
-import {DiffLayer} from '../../../types/types';
+import {
+  ActionInfo,
+  ChangeInfo,
+  PatchSetNum,
+  ReviewInput,
+  RevisionInfo,
+} from '../../../types/common';
+import {EventType, TargetElement} from '../../../api/plugin';
+import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
-import {MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
+import {MenuLink} from '../../../api/admin';
 
 export interface ShowChangeDetail {
   change: ChangeInfo;
@@ -31,6 +37,7 @@
   revisionActions: {[key: string]: ActionInfo};
 }
 
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type EventCallback = (...args: any[]) => any;
 
 export interface JsApiService {
@@ -41,6 +48,7 @@
     revertSubmissionMsg: string,
     origMsg: string
   ): string;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   handleEvent(eventName: EventType, detail: any): void;
   modifyRevertMsg(
     change: ChangeInfo,
@@ -48,9 +56,11 @@
     origMsg: string
   ): string;
   addElement(key: TargetElement, el: HTMLElement): void;
-  getDiffLayers(path: string, changeNum: number): DiffLayer[];
+  getDiffLayers(path: string): DiffLayer[];
   disposeDiffLayers(path: string): void;
   getCoverageAnnotationApis(): Promise<GrAnnotationActionsInterface[]>;
   getAdminMenuLinks(): MenuLink[];
-  // TODO(TS): Add more methods when needed for the TS conversion.
+  handleCommitMessage(change: ChangeInfo | ParsedChangeInfo, msg: string): void;
+  canSubmitChange(change: ChangeInfo, revision?: RevisionInfo | null): boolean;
+  getReviewPostRevert(change?: ChangeInfo): ReviewInput;
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index ffdf710..2135c30 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -16,19 +16,19 @@
  */
 
 import {RevisionInfo, ChangeInfo, RequestPayload} from '../../../types/common';
-import {PluginApi} from '../../plugins/gr-plugin-types';
+import {ShowAlertEventDetail} from '../../../types/events';
+import {PluginApi} from '../../../api/plugin';
 import {UIActionInfo} from './gr-change-actions-js-api';
-
-interface GrPopupInterface {
-  close(): void;
-}
+import {windowLocationReload} from '../../../utils/dom-util';
+import {PopupPluginApi} from '../../../api/popup';
+import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
 
 interface ButtonCallBacks {
   onclick: (event: Event) => boolean;
 }
 
 export class GrPluginActionContext {
-  private _popups: GrPopupInterface[] = [];
+  private popups: PopupPluginApi[] = [];
 
   constructor(
     public readonly plugin: PluginApi,
@@ -39,24 +39,24 @@
 
   popup(element: Node) {
     this.plugin.popup().then(popApi => {
-      const popupEl = popApi._getElement();
+      const popupEl = (popApi as GrPopupInterface)._getElement();
       if (!popupEl) {
         throw new Error('Popup element not found');
       }
       popupEl.appendChild(element);
-      this._popups.push(popApi);
+      this.popups.push(popApi);
     });
   }
 
   hide() {
-    for (const popupApi of this._popups) {
+    for (const popupApi of this.popups) {
       popupApi.close();
     }
-    this._popups.splice(0);
+    this.popups.splice(0);
   }
 
   refresh() {
-    window.location.reload();
+    windowLocationReload();
   }
 
   textfield(): HTMLElement {
@@ -117,7 +117,7 @@
       .then(onSuccess)
       .catch((error: unknown) => {
         document.dispatchEvent(
-          new CustomEvent('show-alert', {
+          new CustomEvent<ShowAlertEventDetail>('show-alert', {
             detail: {
               message: `Plugin network error: ${error}`,
             },
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
index e764bf8..34c976a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
@@ -19,6 +19,7 @@
 import './gr-js-api-interface.js';
 import {GrPluginActionContext} from './gr-plugin-action-context.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+import {addListenerForTest} from '../../../test/test-utils.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
@@ -137,7 +138,7 @@
       send: sendStub,
     });
     const errorStub = sinon.stub();
-    document.addEventListener('show-alert', errorStub);
+    addListenerForTest(document, 'show-alert', errorStub);
     instance.call();
     await flush();
     assert.isTrue(errorStub.calledOnce);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
index 3935ef1..0cc95a3 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
@@ -14,11 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import {importHref} from '../../../scripts/import-href';
-import {HookApi, PluginApi} from '../../plugins/gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
 import {notUndefined} from '../../../types/types';
+import {HookApi} from '../../../api/hook';
 
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
 type Callback = (value: any) => void;
 
 export interface ModuleInfo {
@@ -42,16 +42,15 @@
 export class GrPluginEndpoints {
   private readonly _endpoints = new Map<string, ModuleInfo[]>();
 
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   private readonly _callbacks = new Map<string, ((value: any) => void)[]>();
 
   private readonly _dynamicPlugins = new Map<string, Set<string>>();
 
-  private readonly _importedUrls = new Set<string>();
-
-  private _pluginLoaded = false;
+  private pluginLoaded = false;
 
   setPluginsReady() {
-    this._pluginLoaded = true;
+    this.pluginLoaded = true;
   }
 
   onNewEndpoint(endpoint: string, callback: Callback) {
@@ -122,7 +121,7 @@
     // one register before plugins ready
     // the other done after, then only the later one will have the callbacks
     // invoked.
-    if (this._pluginLoaded && this._callbacks.has(endpoint)) {
+    if (this.pluginLoaded && this._callbacks.has(endpoint)) {
       this._callbacks.get(endpoint)!.forEach(callback => callback(moduleInfo));
     }
   }
@@ -176,42 +175,6 @@
       notUndefined
     );
   }
-
-  importUrl(pluginUrl: URL) {
-    let timerId: any;
-    return Promise.race([
-      new Promise((resolve, reject) => {
-        this._importedUrls.add(pluginUrl.href);
-        importHref(pluginUrl.href, resolve, reject);
-      }),
-      // Timeout after 3s
-      new Promise(r => (timerId = setTimeout(r, 3000))),
-    ]).finally(() => {
-      if (timerId) clearTimeout(timerId);
-    });
-  }
-
-  /**
-   * Get plugin URLs with element and module definitions.
-   */
-  getAndImportPlugins(name: string, options?: Options) {
-    return Promise.all(
-      this.getPlugins(name, options).map(pluginUrl => {
-        if (this._importedUrls.has(pluginUrl.href)) {
-          return Promise.resolve();
-        }
-
-        // TODO: we will deprecate html plugins entirely
-        // for now, keep the original behavior and import
-        // only for html ones
-        if (pluginUrl?.pathname.endsWith('.html')) {
-          return this.importUrl(pluginUrl);
-        } else {
-          return Promise.resolve();
-        }
-      })
-    );
-  }
 }
 
 // TODO(dmfilippov): Convert to service and add to appContext
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
index 5b931b4..e3475ad 100644
--- 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
@@ -33,7 +33,7 @@
     domHook = {};
     instance = new GrPluginEndpoints();
     pluginApi.install(p => { pluginFoo = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/foo.html');
+        'http://test.com/plugins/testplugin/static/foo.js');
     instance.registerModule(
         pluginFoo,
         {
@@ -44,7 +44,7 @@
         }
     );
     pluginApi.install(p => { pluginBar = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/bar.html');
+        'http://test.com/plugins/testplugin/static/bar.js');
     instance.registerModule(
         pluginBar,
         {
@@ -54,7 +54,6 @@
           domHook,
         }
     );
-    sinon.spy(instance, 'importUrl');
   });
 
   teardown(() => {
@@ -120,14 +119,6 @@
         instance.getPlugins('a-place'), [pluginFoo._url]);
   });
 
-  test('getAndImportPlugins', () => {
-    instance.getAndImportPlugins('a-place');
-    assert.isTrue(instance.importUrl.called);
-    assert.isTrue(instance.importUrl.calledOnce);
-    instance.getAndImportPlugins('a-place');
-    assert.isTrue(instance.importUrl.calledOnce);
-  });
-
   test('onNewEndpoint', () => {
     const newModuleStub = sinon.stub();
     instance.setPluginsReady();
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index 47b7be3..85b6747 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,7 +15,6 @@
  * limitations under the License.
  */
 import {appContext} from '../../../services/app-context';
-import {importHref} from '../../../scripts/import-href';
 import {
   PLUGIN_LOADING_TIMEOUT_MS,
   PRELOADED_PROTOCOL,
@@ -24,9 +23,10 @@
 import {Plugin} from './gr-public-js-api';
 import {getBaseUrl} from '../../../utils/url-util';
 import {getPluginEndpoints} from './gr-plugin-endpoints';
-import {PluginApi} from '../../plugins/gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {ShowAlertEventDetail} from '../../../types/events';
 
 enum PluginState {
   /** State that indicates the plugin is pending to be loaded. */
@@ -68,12 +68,12 @@
 
 // Prefix for any unrecognized plugin urls.
 // Url should match following patterns:
-// /plugins/PLUGINNAME/static/SCRIPTNAME.(html|js)
-// /plugins/PLUGINNAME.(js|html)
+// /plugins/PLUGINNAME/static/SCRIPTNAME.js
+// /plugins/PLUGINNAME.js
 const UNKNOWN_PLUGIN_PREFIX = '__$$__';
 
 // Current API version for Plugin,
-// plugins with incompatible version will not be laoded.
+// plugins with incompatible version will not be loaded.
 const API_VERSION = '0.1';
 
 /**
@@ -115,7 +115,7 @@
   /**
    * Load multiple plugins with certain options.
    */
-  loadPlugins(plugins: string[] = [], opts: PluginOptionMap = {}) {
+  loadPlugins(plugins: string[] = []) {
     this._pluginListLoaded = true;
 
     plugins.forEach(path => {
@@ -133,9 +133,7 @@
         plugin: null,
       });
 
-      if (this._isPathEndsWith(url, '.html')) {
-        this._importHtmlPlugin(path, opts && opts[path]);
-      } else if (this._isPathEndsWith(url, '.js')) {
+      if (this._isPathEndsWith(url, '.js')) {
         this._loadJsPlugin(path);
       } else {
         this._failToLoad(`Unrecognized plugin path ${path}`, path);
@@ -143,7 +141,6 @@
     });
 
     this.awaitPluginsLoaded().then(() => {
-      console.info('Plugins loaded');
       this._getReporting().pluginsLoaded(this._getAllInstalledPluginNames());
     });
   }
@@ -249,7 +246,7 @@
   _failToLoad(message: string, pluginUrl?: string) {
     // Show an alert with the error
     document.dispatchEvent(
-      new CustomEvent('show-alert', {
+      new CustomEvent<ShowAlertEventDetail>('show-alert', {
         detail: {
           message: `Plugin install error: ${message} from ${pluginUrl}`,
         },
@@ -336,26 +333,6 @@
       : false;
   }
 
-  _importHtmlPlugin(pluginUrl: string, opts: PluginOption = {}) {
-    const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
-    const urlWithoutAP = this._urlFor(pluginUrl);
-    let onerror = undefined;
-    if (urlWithAP !== urlWithoutAP) {
-      onerror = () => this._loadHtmlPlugin(urlWithoutAP, opts.sync);
-    }
-    this._loadHtmlPlugin(urlWithAP, opts.sync, onerror);
-  }
-
-  _loadHtmlPlugin(url: string, sync?: boolean, onerror?: (e: Event) => void) {
-    if (!onerror) {
-      onerror = () => {
-        this._failToLoad(`${url} import error`, url);
-      };
-    }
-
-    importHref(url, () => {}, onerror, !sync);
-  }
-
   _loadJsPlugin(pluginUrl: string) {
     const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
     const urlWithoutAP = this._urlFor(pluginUrl);
@@ -385,9 +362,7 @@
 
   _urlFor(pathOrUrl: string, assetsPath?: string): string {
     // theme is per host, should always load from assetsPath
-    const isThemeFile =
-      pathOrUrl.endsWith('static/gerrit-theme.html') ||
-      pathOrUrl.endsWith('static/gerrit-theme.js');
+    const isThemeFile = pathOrUrl.endsWith('static/gerrit-theme.js');
     const shouldTryLoadFromAssetsPathFirst = !isThemeFile && assetsPath;
     if (
       pathOrUrl.startsWith(PRELOADED_PROTOCOL) ||
@@ -424,14 +399,15 @@
       return Promise.resolve();
     }
     if (!this._loadingPromise) {
-      // TODO(TS): Should be a number, but TS thinks that is must be some weird
-      // NodeJS.Timeout object.
-      let timerId: any;
+      // specify window here so that TS pulls the correct setTimeout method
+      // if window is not specified, then the function is pulled from node
+      // and the return type is NodeJS.Timeout object
+      let timerId: number;
       this._loadingPromise = Promise.race([
-        new Promise(resolve => (this._loadingResolver = resolve)),
+        new Promise<void>(resolve => (this._loadingResolver = resolve)),
         new Promise(
           (_, reject) =>
-            (timerId = setTimeout(() => {
+            (timerId = window.setTimeout(() => {
               reject(new Error(this._timeout()));
             }, PLUGIN_LOADING_TIMEOUT_MS))
         ),
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
index 584cd39..1ca7e75 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
@@ -16,14 +16,11 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import './gr-js-api-interface.js';
 import {PRELOADED_PROTOCOL, PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
 import {_testOnly_resetPluginLoader} from './gr-plugin-loader.js';
 import {resetPlugins, stubBaseUrl} from '../../../test/test-utils.js';
-import {_testOnly_flushPreinstalls} from './gr-gerrit.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const basicFixture = fixtureFromElement('gr-js-api-interface');
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
@@ -31,25 +28,16 @@
   let plugin;
 
   let url;
-  let sendStub;
   let pluginLoader;
   let clock;
 
   setup(() => {
     clock = sinon.useFakeTimers();
 
-    sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
-    stub('gr-rest-api-interface', {
-      getAccount() {
-        return Promise.resolve({name: 'Judy Hopps'});
-      },
-      send(...args) {
-        return sendStub(...args);
-      },
-    });
+    stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
+    stubRestApi('send').returns(Promise.resolve({status: 200}));
     pluginLoader = _testOnly_resetPluginLoader();
     sinon.stub(document.body, 'appendChild');
-    basicFixture.instantiate();
     url = window.location.origin;
   });
 
@@ -68,16 +56,6 @@
     assert.strictEqual(plugin, otherPlugin);
   });
 
-  test('flushes preinstalls if provided', () => {
-    assert.doesNotThrow(() => {
-      _testOnly_flushPreinstalls();
-    });
-    window.Gerrit.flushPreinstalls = sinon.stub();
-    _testOnly_flushPreinstalls();
-    assert.isTrue(window.Gerrit.flushPreinstalls.calledOnce);
-    delete window.Gerrit.flushPreinstalls;
-  });
-
   test('versioning', () => {
     const callback = sinon.spy();
     pluginApi.install(callback, '0.0pre-alpha');
@@ -156,7 +134,7 @@
     ];
 
     const alertStub = sinon.stub();
-    document.addEventListener('show-alert', alertStub);
+    addListenerForTest(document, 'show-alert', alertStub);
 
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
       pluginApi.install(() => {
@@ -184,7 +162,7 @@
     ];
 
     const alertStub = sinon.stub();
-    document.addEventListener('show-alert', alertStub);
+    addListenerForTest(document, 'show-alert', alertStub);
 
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
       pluginApi.install(() => {
@@ -217,7 +195,7 @@
     ];
 
     const alertStub = sinon.stub();
-    document.addEventListener('show-alert', alertStub);
+    addListenerForTest(document, 'show-alert', alertStub);
 
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
       pluginApi.install(() => {
@@ -236,14 +214,14 @@
     assert.isTrue(alertStub.calledTwice);
   });
 
-  test('plugins installed failed becasue of wrong version', async () => {
+  test('plugins installed failed because of wrong version', async () => {
     const plugins = [
       'http://test.com/plugins/foo/static/test.js',
       'http://test.com/plugins/bar/static/test.js',
     ];
 
     const alertStub = sinon.stub();
-    document.addEventListener('show-alert', alertStub);
+    addListenerForTest(document, 'show-alert', alertStub);
 
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
       pluginApi.install(() => {
@@ -281,13 +259,8 @@
   });
 
   suite('plugin path and url', () => {
-    let importHtmlPluginStub;
     let loadJsPluginStub;
     setup(() => {
-      importHtmlPluginStub = sinon.stub();
-      sinon.stub(pluginLoader, '_loadHtmlPlugin').callsFake( url => {
-        importHtmlPluginStub(url);
-      });
       loadJsPluginStub = sinon.stub();
       sinon.stub(pluginLoader, '_createScriptTag').callsFake( url => {
         loadJsPluginStub(url);
@@ -314,13 +287,8 @@
     test('relative path for plugins', () => {
       pluginLoader.loadPlugins([
         'foo/bar.js',
-        'foo/bar.html',
       ]);
 
-      assert.isTrue(importHtmlPluginStub.calledOnce);
-      assert.isTrue(
-          importHtmlPluginStub.calledWithExactly(`${url}/foo/bar.html`)
-      );
       assert.isTrue(loadJsPluginStub.calledOnce);
       assert.isTrue(
           loadJsPluginStub.calledWithExactly(`${url}/foo/bar.js`)
@@ -333,17 +301,10 @@
 
       pluginLoader.loadPlugins([
         'foo/bar.js',
-        'foo/bar.html',
       ]);
 
-      assert.isTrue(importHtmlPluginStub.calledOnce);
       assert.isTrue(loadJsPluginStub.calledOnce);
       assert.isTrue(
-          importHtmlPluginStub.calledWithExactly(
-              `${url}${testUrl}/foo/bar.html`
-          )
-      );
-      assert.isTrue(
           loadJsPluginStub.calledWithExactly(`${url}${testUrl}/foo/bar.js`)
       );
     });
@@ -351,13 +312,8 @@
     test('absolute path for plugins', () => {
       pluginLoader.loadPlugins([
         'http://e.com/foo/bar.js',
-        'http://e.com/foo/bar.html',
       ]);
 
-      assert.isTrue(importHtmlPluginStub.calledOnce);
-      assert.isTrue(
-          importHtmlPluginStub.calledWithExactly(`http://e.com/foo/bar.html`)
-      );
       assert.isTrue(loadJsPluginStub.calledOnce);
       assert.isTrue(
           loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`)
@@ -366,14 +322,9 @@
   });
 
   suite('With ASSETS_PATH', () => {
-    let importHtmlPluginStub;
     let loadJsPluginStub;
     setup(() => {
       window.ASSETS_PATH = 'https://cdn.com';
-      importHtmlPluginStub = sinon.stub();
-      sinon.stub(pluginLoader, '_loadHtmlPlugin').callsFake( url => {
-        importHtmlPluginStub(url);
-      });
       loadJsPluginStub = sinon.stub();
       sinon.stub(pluginLoader, '_createScriptTag').callsFake( url => {
         loadJsPluginStub(url);
@@ -387,13 +338,8 @@
     test('Should try load plugins from assets path instead', () => {
       pluginLoader.loadPlugins([
         'foo/bar.js',
-        'foo/bar.html',
       ]);
 
-      assert.isTrue(importHtmlPluginStub.calledOnce);
-      assert.isTrue(
-          importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
-      );
       assert.isTrue(loadJsPluginStub.calledOnce);
       assert.isTrue(
           loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
@@ -401,14 +347,9 @@
 
     test('Should honor original path if exists', () => {
       pluginLoader.loadPlugins([
-        'http://e.com/foo/bar.html',
         'http://e.com/foo/bar.js',
       ]);
 
-      assert.isTrue(importHtmlPluginStub.calledOnce);
-      assert.isTrue(
-          importHtmlPluginStub.calledWithExactly(`http://e.com/foo/bar.html`)
-      );
       assert.isTrue(loadJsPluginStub.calledOnce);
       assert.isTrue(
           loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`));
@@ -417,14 +358,9 @@
     test('Should try replace current host with assetsPath', () => {
       const host = window.location.origin;
       pluginLoader.loadPlugins([
-        `${host}/foo/bar.html`,
         `${host}/foo/bar.js`,
       ]);
 
-      assert.isTrue(importHtmlPluginStub.calledOnce);
-      assert.isTrue(
-          importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
-      );
       assert.isTrue(loadJsPluginStub.calledOnce);
       assert.isTrue(
           loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
@@ -467,10 +403,6 @@
       window.Gerrit._preloadedPlugins = null;
     });
     test('skips preloaded plugins when load plugins', () => {
-      const importHtmlPluginStub = sinon.stub();
-      sinon.stub(pluginLoader, '_importHtmlPlugin').callsFake( url => {
-        importHtmlPluginStub(url);
-      });
       const loadJsPluginStub = sinon.stub();
       sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
         loadJsPluginStub(url);
@@ -483,11 +415,9 @@
 
       pluginLoader.loadPlugins([
         'http://e.com/plugins/foo.js',
-        'plugins/bar.html',
         'http://e.com/plugins/test/foo.js',
       ]);
 
-      assert.isTrue(importHtmlPluginStub.notCalled);
       assert.isTrue(loadJsPluginStub.calledOnce);
     });
 
@@ -513,8 +443,8 @@
       let plugin;
       pluginApi.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
       assert.strictEqual(plugin.getPluginName(), 'foo');
-      assert.strictEqual(plugin.url('/some/thing.html'),
-          `${window.location.origin}/plugins/foo/some/thing.html`);
+      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 00b2963..150c45a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
@@ -14,57 +14,68 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {HttpMethod} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback, RestPluginApi} from '../../../api/rest';
+import {PluginApi} from '../../../api/plugin';
 
-let restApi: RestApiService | null = null;
-
-export function _testOnlyResetRestApi() {
-  restApi = null;
+async function getErrorMessage(response: Response): Promise<string> {
+  const text = await response.text();
+  return text || `${response.status}`;
 }
 
-function getRestApi(): RestApiService {
-  if (!restApi) {
-    restApi = (document.createElement(
-      'gr-rest-api-interface'
-    ) as unknown) as RestApiService;
+// This is an internal error, that must never be visible outside of this
+// file. It is used only inside GrPluginRestApi.send method. See detailed
+// explanation in the GrPluginRestApi.send method.
+class ResponseError extends Error {
+  public constructor(readonly response: Response) {
+    super();
   }
-  return restApi;
 }
 
-export class GrPluginRestApi {
-  constructor(private readonly prefix = '') {}
+export class GrPluginRestApi implements RestPluginApi {
+  private readonly restApi = appContext.restApiService;
+
+  private readonly reporting = appContext.reportingService;
+
+  constructor(readonly plugin: PluginApi, private readonly prefix = '') {
+    this.reporting.trackApi(this.plugin, 'rest', 'constructor');
+  }
 
   getLoggedIn() {
-    return getRestApi().getLoggedIn();
+    this.reporting.trackApi(this.plugin, 'rest', 'getLoggedIn');
+    return this.restApi.getLoggedIn();
   }
 
   getVersion() {
-    return getRestApi().getVersion();
+    this.reporting.trackApi(this.plugin, 'rest', 'getVersion');
+    return this.restApi.getVersion();
   }
 
   getConfig() {
-    return getRestApi().getConfig();
+    this.reporting.trackApi(this.plugin, 'rest', 'getConfig');
+    return this.restApi.getConfig();
   }
 
   invalidateReposCache() {
-    getRestApi().invalidateReposCache();
+    this.reporting.trackApi(this.plugin, 'rest', 'invalidateReposCache');
+    this.restApi.invalidateReposCache();
   }
 
   getAccount() {
-    return getRestApi().getAccount();
+    this.reporting.trackApi(this.plugin, 'rest', 'getAccount');
+    return this.restApi.getAccount();
   }
 
   getAccountCapabilities(capabilities: string[]) {
-    return getRestApi().getAccountCapabilities(capabilities);
+    this.reporting.trackApi(this.plugin, 'rest', 'getAccountCapabilities');
+    return this.restApi.getAccountCapabilities(capabilities);
   }
 
   getRepos(filter: string, reposPerPage: number, offset?: number) {
-    return getRestApi().getRepos(filter, reposPerPage, offset);
+    this.reporting.trackApi(this.plugin, 'rest', 'getRepos');
+    return this.restApi.getRepos(filter, reposPerPage, offset);
   }
 
   fetch(
@@ -101,7 +112,8 @@
     errFn?: ErrorCallback,
     contentType?: string
   ): Promise<Response | void> {
-    return getRestApi().send(
+    this.reporting.trackApi(this.plugin, 'rest', 'fetch');
+    return this.restApi.send(
       method,
       this.prefix + url,
       payload,
@@ -120,30 +132,56 @@
     errFn?: ErrorCallback,
     contentType?: string
   ) {
-    return this.fetch(method, url, payload, errFn, contentType).then(
-      response => {
+    this.reporting.trackApi(this.plugin, 'rest', 'send');
+    // Plugins typically don't want Gerrit to show error dialogs for failed
+    // requests. So we are defining a default errFn here, even if it is not
+    // explicitly set by the caller.
+    // TODO: We are soon getting rid of the `errFn` altogether. There are only
+    // 2 known usages of errFn in plugins: delete-project and verify-status.
+    errFn =
+      errFn ??
+      ((response: Response | null | undefined, error?: Error) => {
+        if (error) throw error;
+        // Some plugins show an error message if send is failed, smth like:
+        // pluginApi.send(...).catch(err => showError(err));
+        // The response can contain an error text, but getting this text is
+        // an asynchronous operation. At the same time, the errFn must be a
+        // synchronous function.
+        // As a workaround, we throw an ResponseError here and then catch
+        // it inside a catch block below and read the message.
+        if (response) throw new ResponseError(response);
+        throw new Error('Generic REST API error.');
+      });
+    return this.fetch(method, url, payload, errFn, contentType)
+      .then(response => {
+        // Will typically not happen. The response can only be unset, if the
+        // errFn handles the error and then returns void or undefined or null.
+        // But the errFn above always throws.
         if (!response) {
-          // TODO(TS): Fix method definition
-          // If errFn exists and doesn't throw an exception, the fetch method
-          // returns empty response
-          throw new Error('errFn must throw an exception');
+          throw new Error('plugin rest-api call failed');
         }
+        // Will typically not happen. errFn will have dealt with that and the
+        // caller will get a rejected promise already.
         if (response.status < 200 || response.status >= 300) {
-          return response.text().then(text => {
-            if (text) {
-              return Promise.reject(new Error(text));
-            } else {
-              return Promise.reject(new Error(`${response.status}`));
-            }
-          });
+          return getErrorMessage(response).then(msg =>
+            Promise.reject(new Error(msg))
+          );
         } else {
-          return getRestApi().getResponseObject(response);
+          return this.restApi.getResponseObject(response);
         }
-      }
-    );
+      })
+      .catch(err => {
+        if (err instanceof ResponseError) {
+          return getErrorMessage(err.response).then(msg => {
+            throw new Error(msg);
+          });
+        }
+        throw err;
+      });
   }
 
   get(url: string) {
+    this.reporting.trackApi(this.plugin, 'rest', 'get');
     return this.send(HttpMethod.GET, url);
   }
 
@@ -153,6 +191,7 @@
     errFn?: ErrorCallback,
     contentType?: string
   ) {
+    this.reporting.trackApi(this.plugin, 'rest', 'post');
     return this.send(HttpMethod.POST, url, payload, errFn, contentType);
   }
 
@@ -162,10 +201,12 @@
     errFn?: ErrorCallback,
     contentType?: string
   ) {
+    this.reporting.trackApi(this.plugin, 'rest', 'put');
     return this.send(HttpMethod.PUT, url, payload, errFn, contentType);
   }
 
   delete(url: string) {
+    this.reporting.trackApi(this.plugin, 'rest', 'delete');
     return this.fetch(HttpMethod.DELETE, url).then(response => {
       if (response.status !== 204) {
         return response.text().then(text => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
index 53aaa1e..d2b5658 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
@@ -19,31 +19,20 @@
 import './gr-js-api-interface.js';
 import {GrPluginRestApi} from './gr-plugin-rest-api.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
 suite('gr-plugin-rest-api tests', () => {
   let instance;
-
   let getResponseObjectStub;
   let sendStub;
-  let restApiStub;
 
   setup(() => {
-    getResponseObjectStub = sinon.stub().returns(Promise.resolve());
-    sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
-    restApiStub = {
-      getAccount: () => Promise.resolve({name: 'Judy Hopps'}),
-      getResponseObject: getResponseObjectStub,
-      send: sendStub,
-      getLoggedIn: sinon.stub(),
-      getVersion: sinon.stub(),
-      getConfig: sinon.stub(),
-    };
-    stub('gr-rest-api-interface', Object.keys(restApiStub).reduce((a, k) => {
-      a[k] = (...args) => restApiStub[k](...args);
-      return a;
-    }, {}));
+    stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
+    getResponseObjectStub = stubRestApi('getResponseObject').returns(
+        Promise.resolve());
+    sendStub = stubRestApi('send').returns(Promise.resolve({status: 200}));
     pluginApi.install(p => {}, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     instance = new GrPluginRestApi();
@@ -119,25 +108,25 @@
   });
 
   test('getLoggedIn', () => {
-    restApiStub.getLoggedIn.returns(Promise.resolve(true));
+    const stub = stubRestApi('getLoggedIn').returns(Promise.resolve(true));
     return instance.getLoggedIn().then(result => {
-      assert.isTrue(restApiStub.getLoggedIn.calledOnce);
+      assert.isTrue(stub.calledOnce);
       assert.isTrue(result);
     });
   });
 
   test('getVersion', () => {
-    restApiStub.getVersion.returns(Promise.resolve('foo bar'));
+    const stub = stubRestApi('getVersion').returns(Promise.resolve('foo bar'));
     return instance.getVersion().then(result => {
-      assert.isTrue(restApiStub.getVersion.calledOnce);
+      assert.isTrue(stub.calledOnce);
       assert.equal(result, 'foo bar');
     });
   });
 
   test('getConfig', () => {
-    restApiStub.getConfig.returns(Promise.resolve('foo bar'));
+    const stub = stubRestApi('getConfig').returns(Promise.resolve('foo bar'));
     return instance.getConfig().then(result => {
-      assert.isTrue(restApiStub.getConfig.calledOnce);
+      assert.isTrue(stub.calledOnce);
       assert.equal(result, 'foo bar');
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index 0625f67..6ff9a50 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
@@ -14,38 +14,35 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {getBaseUrl} from '../../../utils/url-util';
-import {getSharedApiEl} from '../../../utils/dom-util';
 import {GrAttributeHelper} from '../../plugins/gr-attribute-helper/gr-attribute-helper';
 import {GrChangeActionsInterface} from './gr-change-actions-js-api';
 import {GrChangeReplyInterface} from './gr-change-reply-js-api';
 import {GrDomHooksManager} from '../../plugins/gr-dom-hooks/gr-dom-hooks';
-import {GrThemeApi} from '../../plugins/gr-theme-api/gr-theme-api';
 import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
 import {GrAdminApi} from '../../plugins/gr-admin-api/gr-admin-api';
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
-import {GrChangeMetadataApi} from '../../plugins/gr-change-metadata-api/gr-change-metadata-api';
 import {GrEventHelper} from '../../plugins/gr-event-helper/gr-event-helper';
 import {GrPluginRestApi} from './gr-plugin-rest-api';
-import {GrRepoApi} from '../../plugins/gr-repo-api/gr-repo-api';
-import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api';
-import {GrStylesApi} from '../../plugins/gr-styles-api/gr-styles-api';
 import {getPluginEndpoints} from './gr-plugin-endpoints';
-
-import {PRELOADED_PROTOCOL, getPluginNameFromUrl, send} from './gr-api-utils';
-import {GrReporintJsApi} from './gr-reporting-js-api';
-import {
-  EventType,
-  HookApi,
-  PluginApi,
-  RegisterOptions,
-  TargetElement,
-} from '../../plugins/gr-plugin-types';
+import {getPluginNameFromUrl, PRELOADED_PROTOCOL, send} from './gr-api-utils';
+import {GrReportingJsApi} from './gr-reporting-js-api';
+import {EventType, PluginApi, TargetElement} from '../../../api/plugin';
 import {RequestPayload} from '../../../types/common';
 import {HttpMethod} from '../../../constants/constants';
-import {JsApiService} from './gr-js-api-types';
 import {GrChangeActions} from '../../change/gr-change-actions/gr-change-actions';
+import {GrChecksApi} from '../../plugins/gr-checks-api/gr-checks-api';
+import {appContext} from '../../../services/app-context';
+import {AdminPluginApi} from '../../../api/admin';
+import {AnnotationPluginApi} from '../../../api/annotation';
+import {EventHelperPluginApi} from '../../../api/event-helper';
+import {PopupPluginApi} from '../../../api/popup';
+import {ReportingPluginApi} from '../../../api/reporting';
+import {ChangeActionsPluginApi} from '../../../api/change-actions';
+import {ChangeReplyPluginApi} from '../../../api/change-reply';
+import {RestPluginApi} from '../../../api/rest';
+import {HookApi, RegisterOptions} from '../../../api/hook';
+import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
 
 /**
  * Plugin-provided custom components can affect content in extension
@@ -70,16 +67,16 @@
 export class Plugin implements PluginApi {
   readonly _url?: URL;
 
-  private _domHooks: GrDomHooksManager;
+  private domHooks: GrDomHooksManager;
 
   private readonly _name: string = PLUGIN_NAME_NOT_SET;
 
-  // TODO(TS): Change type to GrJsApiInterface
-  private readonly sharedApiElement: JsApiService;
+  private readonly jsApi = appContext.jsApiService;
+
+  private readonly report = appContext.reportingService;
 
   constructor(url?: string) {
-    this.sharedApiElement = getSharedApiEl();
-    this._domHooks = new GrDomHooksManager(this);
+    this.domHooks = new GrDomHooksManager(this);
 
     if (!url) {
       console.warn(
@@ -91,6 +88,7 @@
 
     this._url = new URL(url);
     this._name = getPluginNameFromUrl(this._url) ?? 'NULL';
+    this.report.trackApi(this, 'plugin', 'constructor');
   }
 
   getPluginName() {
@@ -98,6 +96,7 @@
   }
 
   registerStyleModule(endpoint: string, moduleName: string) {
+    this.report.trackApi(this, 'plugin', 'registerStyleModule');
     getPluginEndpoints().registerModule(this, {
       endpoint,
       type: EndpointType.STYLE,
@@ -113,6 +112,7 @@
     moduleName?: string,
     options?: RegisterOptions
   ): HookApi {
+    this.report.trackApi(this, 'plugin', 'registerCustomComponent');
     return this._registerCustomComponent(endpointName, moduleName, options);
   }
 
@@ -127,6 +127,7 @@
     moduleName?: string,
     options?: RegisterOptions
   ): HookApi {
+    this.report.trackApi(this, 'plugin', 'registerDynamicCustomComponent');
     const fullEndpointName = `${endpointName}-${this.getPluginName()}`;
     return this._registerCustomComponent(
       fullEndpointName,
@@ -145,7 +146,7 @@
     const type =
       options && options.replace ? EndpointType.REPLACE : EndpointType.DECORATE;
     const slot = (options && options.slot) || '';
-    const domHook = this._domHooks.getDomHook(endpoint, moduleName);
+    const domHook = this.domHooks.getDomHook(endpoint, moduleName);
     moduleName = moduleName || domHook.getModuleName();
     getPluginEndpoints().registerModule(this, {
       slot,
@@ -163,18 +164,23 @@
    * element for the first call.
    */
   hook(endpointName: string, options?: RegisterOptions) {
+    this.report.trackApi(this, 'plugin', 'hook');
     return this.registerCustomComponent(endpointName, undefined, options);
   }
 
   getServerInfo() {
-    return document.createElement('gr-rest-api-interface').getConfig();
+    this.report.trackApi(this, 'plugin', 'getServerInfo');
+    return appContext.restApiService.getConfig();
   }
 
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   on(eventName: EventType, callback: (...args: any[]) => any) {
-    this.sharedApiElement.addEventCallback(eventName, callback);
+    this.report.trackApi(this, 'plugin', 'on');
+    this.jsApi.addEventCallback(eventName, callback);
   }
 
   url(path?: string) {
+    this.report.trackApi(this, 'plugin', 'url');
     if (!this._url) throw new Error('plugin url not set');
     const relPath = '/plugins/' + this._name + (path || '/');
     const sameOriginPath = window.location.origin + `${getBaseUrl()}${relPath}`;
@@ -193,6 +199,7 @@
   }
 
   screenUrl(screenName?: string) {
+    this.report.trackApi(this, 'plugin', 'screenUrl');
     const origin = location.origin;
     const base = getBaseUrl();
     const tokenPart = screenName ? '/' + screenName : '';
@@ -208,100 +215,52 @@
     return send(method, this.url(url), callback, payload);
   }
 
-  get(url: string, callback?: SendCallback) {
-    console.warn('.get() is deprecated! Use .restApi().get()');
-    return this._send(HttpMethod.GET, url, callback);
-  }
-
-  post(url: string, payload: RequestPayload, callback?: SendCallback) {
-    console.warn('.post() is deprecated! Use .restApi().post()');
-    return this._send(HttpMethod.POST, url, callback, payload);
-  }
-
-  put(url: string, payload: RequestPayload, callback?: SendCallback) {
-    console.warn('.put() is deprecated! Use .restApi().put()');
-    return this._send(HttpMethod.PUT, url, callback, payload);
-  }
-
-  delete(url: string, callback?: SendCallback) {
-    console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
-    return this.restApi()
-      .delete(this.url(url))
-      .then(res => {
-        if (callback) callback(res);
-        return res;
-      });
-  }
-
-  annotationApi() {
+  annotationApi(): AnnotationPluginApi {
     return new GrAnnotationActionsInterface(this);
   }
 
-  changeActions() {
+  changeActions(): ChangeActionsPluginApi {
     return new GrChangeActionsInterface(
       this,
-      (this.sharedApiElement.getElement(
+      (this.jsApi.getElement(
         TargetElement.CHANGE_ACTIONS
       ) as unknown) as GrChangeActions
     );
   }
 
-  changeReply() {
-    return new GrChangeReplyInterface(this, this.sharedApiElement);
+  changeReply(): ChangeReplyPluginApi {
+    return new GrChangeReplyInterface(this, this.jsApi);
   }
 
-  reporting() {
-    return new GrReporintJsApi(this);
+  checks(): GrChecksApi {
+    return new GrChecksApi(this);
   }
 
-  theme() {
-    return new GrThemeApi(this);
+  reporting(): ReportingPluginApi {
+    return new GrReportingJsApi(this);
   }
 
-  project() {
-    return new GrRepoApi(this);
-  }
-
-  changeMetadata() {
-    return new GrChangeMetadataApi(this);
-  }
-
-  admin() {
+  admin(): AdminPluginApi {
     return new GrAdminApi(this);
   }
 
-  settings() {
-    return new GrSettingsApi(this);
+  restApi(prefix?: string): RestPluginApi {
+    return new GrPluginRestApi(this, prefix);
   }
 
-  styles() {
-    return new GrStylesApi();
+  attributeHelper(element: HTMLElement): AttributeHelperPluginApi {
+    return new GrAttributeHelper(this, element);
   }
 
-  /**
-   * To make REST requests for plugin-provided endpoints, use
-   *
-   * @example
-   * const pluginRestApi = plugin.restApi(plugin.url());
-   * @param prefix url for subsequent .get(), .post() etc requests.
-   */
-  restApi(prefix?: string) {
-    return new GrPluginRestApi(prefix);
+  eventHelper(element: HTMLElement): EventHelperPluginApi {
+    return new GrEventHelper(this, element);
   }
 
-  attributeHelper(element: HTMLElement) {
-    return new GrAttributeHelper(element);
-  }
+  popup(): Promise<PopupPluginApi>;
 
-  eventHelper(element: HTMLElement) {
-    return new GrEventHelper(element);
-  }
+  popup(moduleName: string): Promise<PopupPluginApi>;
 
-  popup(): Promise<GrPopupInterface>;
-
-  popup(moduleName: string): Promise<GrPopupInterface>;
-
-  popup(moduleName?: string): Promise<GrPopupInterface | null> {
+  popup(moduleName?: string): Promise<PopupPluginApi | null> {
     if (moduleName !== undefined && typeof moduleName !== 'string') {
       console.error('.popup(element) deprecated, use .popup(moduleName)!');
       return Promise.resolve(null);
@@ -310,6 +269,7 @@
   }
 
   screen(screenName: string, moduleName?: string) {
+    this.report.trackApi(this, 'plugin', 'screen');
     if (moduleName && typeof moduleName !== 'string') {
       console.error(
         '.screen(pattern, callback) deprecated, use ' +
@@ -324,6 +284,7 @@
   }
 
   _getScreenName(screenName: string) {
+    this.report.trackApi(this, 'plugin', '_getScreenName');
     return `${this.getPluginName()}-screen-${screenName}`;
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
index 0bf6676..595fb4f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
@@ -16,32 +16,34 @@
  */
 
 import {appContext} from '../../../services/app-context';
-import {EventDetails} from '../../../services/gr-reporting/gr-reporting';
-
-// TODO(TS): remove once Plugin api converted to ts
-interface PluginApi {
-  getPluginName(): string;
-}
+import {PluginApi} from '../../../api/plugin';
+import {EventDetails, ReportingPluginApi} from '../../../api/reporting';
+import {LifeCycle} from '../../../constants/reporting';
 
 /**
  * Defines all methods that will be exported to plugin from reporting service.
  */
-export class GrReporintJsApi {
+export class GrReportingJsApi implements ReportingPluginApi {
   private readonly reporting = appContext.reportingService;
 
-  constructor(private readonly plugin: PluginApi) {}
+  constructor(private readonly plugin: PluginApi) {
+    this.reporting.trackApi(this.plugin, 'reporting', 'constructor');
+  }
 
   reportInteraction(eventName: string, details?: EventDetails) {
-    return this.reporting.reportInteraction(
+    this.reporting.trackApi(this.plugin, 'reporting', 'reportInteraction');
+    this.reporting.reportInteraction(
       `${this.plugin.getPluginName()}-${eventName}`,
       details
     );
   }
 
   reportLifeCycle(eventName: string, details?: EventDetails) {
-    return this.reporting.reportLifeCycle(
-      `${this.plugin.getPluginName()}-${eventName}`,
-      details
-    );
+    this.reporting.trackApi(this.plugin, 'reporting', 'reportLifeCycle');
+    this.reporting.reportLifeCycle(LifeCycle.PLUGIN_LIFE_CYCLE, {
+      ...details,
+      pluginName: this.plugin.getPluginName(),
+      eventName,
+    });
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
index 1229641..ffc2fbb 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
@@ -19,6 +19,7 @@
 import '../../change/gr-reply-dialog/gr-reply-dialog.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 import {appContext} from '../../../services/app-context.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
@@ -27,10 +28,7 @@
   let plugin;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getAccount() { return Promise.resolve(null); },
-    });
+    stubRestApi('getAccount').returns(Promise.resolve(null));
   });
 
   suite('early init', () => {
@@ -64,12 +62,12 @@
       assert.isTrue(appContext.reportingService.reportLifeCycle.called);
       assert.equal(
           appContext.reportingService.reportLifeCycle.lastCall.args[0],
-          'testplugin-test'
+          'Plugin life cycle'
       );
       assert.deepEqual(
           appContext.reportingService.reportLifeCycle.lastCall.args[1],
-          {}
+          {pluginName: 'testplugin', eventName: 'test'}
       );
     });
   });
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index 1dac371..db22ce5 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -21,10 +21,7 @@
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
 import '../gr-label/gr-label';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-label-info_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
@@ -38,15 +35,9 @@
   isQuickLabelInfo,
   isDetailedLabelInfo,
 } from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrButton} from '../gr-button/gr-button';
 import {getVotingRangeOrDefault} from '../../../utils/label-util';
-
-export interface GrLabelInfo {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -68,9 +59,7 @@
 }
 
 @customElement('gr-label-info')
-export class GrLabelInfo extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrLabelInfo extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -90,6 +79,8 @@
   @property({type: Boolean})
   mutable = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   // TODO(TS): not used, remove later
   _xhrPromise?: Promise<void>;
 
@@ -206,7 +197,7 @@
     const accountID = Number(
       `${target.getAttribute('data-account-id')}`
     ) as AccountId;
-    this._xhrPromise = this.$.restAPI
+    this._xhrPromise = this.restApiService
       .deleteVote(this.change._number, accountID, this.label)
       .then(response => {
         target.disabled = false;
@@ -228,7 +219,7 @@
     if (
       !labelInfo ||
       !isDetailedLabelInfo(labelInfo) ||
-      !labelInfo.values[score]
+      !labelInfo.values?.[score]
     ) {
       return '';
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
index 3955cd4..56ab8c6 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
@@ -31,9 +31,9 @@
       display: flex;
       justify-content: center;
       margin-right: var(--spacing-s);
-      padding: 0;
+      padding: 1px;
       @apply --vote-chip-styles;
-      border-width: 0;
+      border: 1px solid var(--border-color);
     }
     .max {
       background-color: var(--vote-color-approved);
@@ -43,9 +43,15 @@
     }
     .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;
@@ -68,7 +74,7 @@
       color: var(--border-color);
     }
     gr-account-link {
-      --account-max-length: 120px;
+      --account-max-length: 100px;
       margin-right: var(--spacing-xs);
     }
     iron-icon {
@@ -95,13 +101,16 @@
           <gr-label
             has-tooltip=""
             title="[[_computeValueTooltip(labelInfo, mappedLabel.value)]]"
-            class$="[[mappedLabel.className]] voteChip"
+            class$="[[mappedLabel.className]] voteChip font-small"
           >
             [[mappedLabel.value]]
           </gr-label>
         </td>
         <td>
-          <gr-account-link account="[[mappedLabel.account]]"></gr-account-link>
+          <gr-account-link
+            account="[[mappedLabel.account]]"
+            change="[[change]]"
+          ></gr-account-link>
         </td>
         <td>
           <gr-button
@@ -118,5 +127,4 @@
       </tr>
     </template>
   </table>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
index 3a2cc39..e8a38ec 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
@@ -17,7 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-label-info.js';
-import {isHidden} from '../../../test/test-utils.js';
+import {isHidden, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-label-info');
 
@@ -72,8 +72,7 @@
 
     test('deletes votes', () => {
       const deleteResponse = Promise.resolve({ok: true});
-      const deleteStub = sinon.stub(
-          element.$.restAPI, 'deleteVote').returns(deleteResponse);
+      const deleteStub = stubRestApi('deleteVote').returns(deleteResponse);
 
       element.change.removable_reviewers = [element.account];
       element.change.labels.test.recommended = {_account_id: 1};
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
index 46b10cc..c13ca6e 100644
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
@@ -21,8 +21,6 @@
  * used in gr-label-info.
  */
 
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement} from '@polymer/decorators';
 import {htmlTemplate} from './gr-label_html';
@@ -35,9 +33,7 @@
 }
 
 @customElement('gr-label')
-export class GrLabel extends TooltipMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrLabel extends TooltipMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
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 4240c77..4be79e2 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
@@ -16,8 +16,6 @@
  */
 import '../gr-autocomplete/gr-autocomplete';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-labeled-autocomplete_html';
 import {customElement, property} from '@polymer/decorators';
@@ -32,9 +30,7 @@
   };
 }
 @customElement('gr-labeled-autocomplete')
-export class GrLabeledAutocomplete extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrLabeledAutocomplete extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
index ad97d02..655acde 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
@@ -15,14 +15,9 @@
  * limitations under the License.
  */
 import '../gr-js-api-interface/gr-js-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-lib-loader_html';
-import {EventType} from '../../plugins/gr-plugin-types';
-import {customElement, property} from '@polymer/decorators';
-import {JsApiService} from '../gr-js-api-interface/gr-js-api-types';
+import {EventType} from '../../../api/plugin';
 import {HighlightJS} from '../../../types/types';
+import {appContext} from '../../../services/app-context';
 
 // preloaded in PolyGerritIndexHtml.soy
 const HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
@@ -35,21 +30,9 @@
   callbacks: HljsCallback[];
 }
 
-export interface GrLibLoader {
-  $: {
-    jsAPI: JsApiService & Element;
-  };
-}
-@customElement('gr-lib-loader')
-export class GrLibLoader extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrLibLoader {
+  private readonly jsAPI = appContext.jsApiService;
 
-  // NOTE: intended singleton.
-  @property({type: Object})
   _hljsState: HljsState = {
     configured: false,
     loading: false,
@@ -87,7 +70,7 @@
   _onHLJSLibLoaded() {
     const lib = this._getHighlightLib();
     this._hljsState.loading = false;
-    this.$.jsAPI.handleEvent(EventType.HIGHLIGHTJS_LOADED, {
+    this.jsAPI.handleEvent(EventType.HIGHLIGHTJS_LOADED, {
       hljs: lib,
     });
     for (const cb of this._hljsState.callbacks) {
@@ -152,9 +135,3 @@
     return root + HLJS_PATH;
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-lib-loader': GrLibLoader;
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.ts
deleted file mode 100644
index f34f99e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
index 1ce175f..c89ff8e 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
@@ -17,23 +17,22 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-lib-loader.js';
-
-const basicFixture = fixtureFromElement('gr-lib-loader');
+import {GrLibLoader} from './gr-lib-loader.js';
 
 suite('gr-lib-loader tests', () => {
-  let element;
+  let grLibLoader;
   let resolveLoad;
   let loadStub;
 
   setup(() => {
-    element = basicFixture.instantiate();
+    grLibLoader = new GrLibLoader();
 
-    loadStub = sinon.stub(element, '_loadScript').callsFake(() =>
+    loadStub = sinon.stub(grLibLoader, '_loadScript').callsFake(() =>
       new Promise(resolve => resolveLoad = resolve)
     );
 
     // Assert preconditions:
-    assert.isFalse(element._hljsState.loading);
+    assert.isFalse(grLibLoader._hljsState.loading);
   });
 
   teardown(() => {
@@ -42,26 +41,26 @@
     }
 
     // Because the element state is a singleton, clean it up.
-    element._hljsState.configured = false;
-    element._hljsState.loading = false;
-    element._hljsState.callbacks = [];
+    grLibLoader._hljsState.configured = false;
+    grLibLoader._hljsState.loading = false;
+    grLibLoader._hljsState.callbacks = [];
   });
 
   test('only load once', async () => {
-    sinon.stub(element, '_getHLJSUrl').returns('');
+    sinon.stub(grLibLoader, '_getHLJSUrl').returns('');
     const firstCallHandler = sinon.stub();
-    element.getHLJS().then(firstCallHandler);
+    grLibLoader.getHLJS().then(firstCallHandler);
 
     // It should now be in the loading state.
     assert.isTrue(loadStub.called);
-    assert.isTrue(element._hljsState.loading);
+    assert.isTrue(grLibLoader._hljsState.loading);
     assert.isFalse(firstCallHandler.called);
 
     const secondCallHandler = sinon.stub();
-    element.getHLJS().then(secondCallHandler);
+    grLibLoader.getHLJS().then(secondCallHandler);
 
     // No change in state.
-    assert.isTrue(element._hljsState.loading);
+    assert.isTrue(grLibLoader._hljsState.loading);
     assert.isFalse(firstCallHandler.called);
     assert.isFalse(secondCallHandler.called);
 
@@ -69,7 +68,7 @@
     resolveLoad();
     await flush();
     // The state should be loaded and both handlers called.
-    assert.isFalse(element._hljsState.loading);
+    assert.isFalse(grLibLoader._hljsState.loading);
     assert.isTrue(firstCallHandler.called);
     assert.isTrue(secondCallHandler.called);
   });
@@ -90,13 +89,13 @@
 
     test('returns hljs', async () => {
       const firstCallHandler = sinon.stub();
-      element.getHLJS().then(firstCallHandler);
+      grLibLoader.getHLJS().then(firstCallHandler);
       await flush();
       assert.isTrue(firstCallHandler.called);
       assert.isTrue(firstCallHandler.calledWith(hljsStub));
     });
 
-    test('configures hljs', () => element.getHLJS().then(() => {
+    test('configures hljs', () => grLibLoader.getHLJS().then(() => {
       assert.isTrue(window.hljs.configure.calledOnce);
     }));
   });
@@ -106,16 +105,16 @@
       let root;
 
       setup(() => {
-        sinon.stub(element, '_getLibRoot').callsFake(() => root);
+        sinon.stub(grLibLoader, '_getLibRoot').callsFake(() => root);
       });
 
       test('with no root', () => {
-        assert.isNull(element._getHLJSUrl());
+        assert.isNull(grLibLoader._getHLJSUrl());
       });
 
       test('with root', () => {
         root = 'test-root.com/';
-        assert.equal(element._getHLJSUrl(),
+        assert.equal(grLibLoader._getHLJSUrl(),
             'test-root.com/bower_components/highlightjs/highlight.min.js');
       });
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
index 4d65874..f76f8d7 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,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-limited-text_html';
 import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
@@ -34,9 +32,7 @@
  * and a tooltip containing the full text is enabled.
  */
 @customElement('gr-limited-text')
-export class GrLimitedText extends TooltipMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrLimitedText extends TooltipMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
index dbb8725..615eac8 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
@@ -19,11 +19,10 @@
 import '../gr-icons/gr-icons';
 import '../gr-limited-text/gr-limited-text';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-linked-chip_html';
+import {fireEvent} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -32,9 +31,7 @@
 }
 
 @customElement('gr-linked-chip')
-export class GrLinkedChip extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrLinkedChip extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -64,11 +61,6 @@
 
   _handleRemoveTap(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('remove', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'remove');
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
index a335db7..cac88b7 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
@@ -31,7 +31,10 @@
     }
     gr-button.remove {
       --gr-remove-button-style: {
-        border: 0;
+        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;
@@ -46,7 +49,6 @@
     gr-button.remove:focus {
       --gr-button: {
         @apply --gr-remove-button-style;
-        color: #333;
       }
     }
     gr-button.remove {
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
index e2c2d7f..2812b47 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
@@ -15,8 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-linked-text_html';
 import {GrLinkTextParser, LinkTextParserConfig} from './link-text-parser';
@@ -36,9 +34,7 @@
 }
 
 @customElement('gr-linked-text')
-export class GrLinkedText extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrLinkedText extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
index 9066911..36f518b 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
@@ -350,27 +350,27 @@
     // The outputArray is used to store all of the matches found for all
     // patterns.
     const outputArray: CommentLinkItem[] = [];
-    for (const p in config) {
+    for (const [configName, linkInfo] of Object.entries(config)) {
       // TODO(TS): it seems, the following line can be rewritten as:
       // if(enabled === false || enabled === 0 || enabled === '')
       // Should be double-checked before update
       // eslint-disable-next-line eqeqeq
-      if (config[p].enabled != null && config[p].enabled == false) {
+      if (linkInfo.enabled != null && linkInfo.enabled == false) {
         continue;
       }
       // PolyGerrit doesn't use hash-based navigation like the GWT UI.
       // Account for this.
-      const html = config[p].html;
-      const link = config[p].link;
+      const html = linkInfo.html;
+      const link = linkInfo.link;
       if (html) {
-        config[p].html = html.replace(/<a href="#\//g, '<a href="/');
+        linkInfo.html = html.replace(/<a href="#\//g, '<a href="/');
       } else if (link) {
         if (link[0] === '#') {
-          config[p].link = link.substr(1);
+          linkInfo.link = link.substr(1);
         }
       }
 
-      const pattern = new RegExp(config[p].match, 'g');
+      const pattern = new RegExp(linkInfo.match, 'g');
 
       let match;
       let textToCheck = text;
@@ -382,10 +382,10 @@
           pattern,
           // Either html or link has a value. Otherwise an exception is thrown
           // in the code below.
-          (config[p].html || config[p].link)!
+          (linkInfo.html || linkInfo.link)!
         );
 
-        if (config[p].html) {
+        if (linkInfo.html) {
           let i;
           // Skip portion of replacement string that is equal to original to
           // allow overlapping patterns.
@@ -402,7 +402,7 @@
             match[0].length - i,
             outputArray
           );
-        } else if (config[p].link) {
+        } else if (linkInfo.link) {
           this.addLink(
             match[0],
             result,
@@ -413,7 +413,7 @@
         } else {
           throw Error(
             'linkconfig entry ' +
-              p +
+              configName +
               ' doesn’t contain a link or html attribute.'
           );
         }
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
index 342e937..f586f48 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
@@ -18,13 +18,13 @@
 import '@polymer/iron-icon/iron-icon';
 import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-list-view_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
-import {property, observe, customElement} from '@polymer/decorators';
+import {property, customElement} from '@polymer/decorators';
+import {fireEvent} from '../../../utils/event-util';
+import {debounce, DelayedTask} from '../../../utils/async-util';
 
 const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
 
@@ -35,9 +35,7 @@
 }
 
 @customElement('gr-list-view')
-class GrListView extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrListView extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -51,7 +49,7 @@
   @property({type: Number})
   itemsPerPage = 25;
 
-  @property({type: String})
+  @property({type: String, observer: '_filterChanged'})
   filter?: string;
 
   @property({type: Number})
@@ -63,14 +61,16 @@
   @property({type: String})
   path?: string;
 
+  private reloadTask?: DelayedTask;
+
   /** @override */
-  detached() {
-    super.detached();
-    this.cancelDebouncer('reload');
+  disconnectedCallback() {
+    this.reloadTask?.cancel();
+    super.disconnectedCallback();
   }
 
-  @observe('filter')
-  _filterChanged(newFilter: string, oldFilter: string) {
+  _filterChanged(newFilter?: string, oldFilter?: string) {
+    // newFilter can be empty string and then !newFilter === true
     if (!newFilter && !oldFilter) {
       return;
     }
@@ -78,9 +78,9 @@
     this._debounceReload(newFilter);
   }
 
-  _debounceReload(filter: string) {
-    this.debounce(
-      'reload',
+  _debounceReload(filter?: string) {
+    this.reloadTask = debounce(
+      this.reloadTask,
       () => {
         if (this.path) {
           if (filter) {
@@ -96,12 +96,7 @@
   }
 
   _createNewItem() {
-    this.dispatchEvent(
-      new CustomEvent('create-clicked', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'create-clicked');
   }
 
   _computeNavLink(
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
index 957496c..2c48ec0f 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
@@ -15,13 +15,14 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-overlay_html';
 import {IronOverlayMixin} from '../../../mixins/iron-overlay-mixin/iron-overlay-mixin';
-import {customElement, property} from '@polymer/decorators';
+import {customElement} from '@polymer/decorators';
 import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
+import {findActiveElement} from '../../../utils/dom-util';
+import {fireEvent} from '../../../utils/event-util';
+import {getHovercardContainer} from '../gr-hovercard/gr-hovercard-behavior';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -35,7 +36,7 @@
 
 @customElement('gr-overlay')
 export class GrOverlay extends IronOverlayMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement)),
+  PolymerElement,
   IronOverlayBehavior as IronOverlayBehavior
 ) {
   static get template() {
@@ -54,12 +55,13 @@
    * @event fullscreen-overlay-opened
    */
 
-  @property({type: Boolean})
-  private _fullScreenOpen = false;
+  private fullScreenOpen = false;
 
   private _boundHandleClose: () => void = () => super.close();
 
-  private focusableNodes: Node[] | undefined;
+  private focusableNodes?: Node[];
+
+  private returnFocusTo?: HTMLElement;
 
   get _focusableNodes() {
     if (this.focusableNodes) {
@@ -76,12 +78,11 @@
     // once the type contains the exported member,
     // should replace with:
     // import {IronFocusablesHelper} from '@polymer/iron-overlay-behavior/iron-focusables-helper';
-    return (window.Polymer as any).IronFocusablesHelper.getTabbableNodes(this);
+    return window.Polymer.IronFocusablesHelper.getTabbableNodes(this);
   }
 
-  /** @override */
-  created() {
-    super.created();
+  constructor() {
+    super();
     this.addEventListener('iron-overlay-closed', () => this._overlayClosed());
     this.addEventListener('iron-overlay-cancelled', () =>
       this._overlayClosed()
@@ -89,17 +90,13 @@
   }
 
   open() {
+    this.returnFocusTo = findActiveElement(document, true) ?? undefined;
     window.addEventListener('popstate', this._boundHandleClose);
-    return new Promise((resolve, reject) => {
+    return new Promise<void>((resolve, reject) => {
       super.open.apply(this);
       if (this._isMobile()) {
-        this.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-opened', {
-            composed: true,
-            bubbles: true,
-          })
-        );
-        this._fullScreenOpen = true;
+        fireEvent(this, 'fullscreen-overlay-opened');
+        this.fullScreenOpen = true;
       }
       this._awaitOpen(resolve, reject);
     });
@@ -112,15 +109,28 @@
   // called after iron-overlay is closed. Does not actually close the overlay
   _overlayClosed() {
     window.removeEventListener('popstate', this._boundHandleClose);
-    if (this._fullScreenOpen) {
-      this.dispatchEvent(
-        new CustomEvent('fullscreen-overlay-closed', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      this._fullScreenOpen = false;
+    if (this.fullScreenOpen) {
+      fireEvent(this, 'fullscreen-overlay-closed');
+      this.fullScreenOpen = false;
     }
+    if (this.returnFocusTo) {
+      this.returnFocusTo.focus();
+      this.returnFocusTo = undefined;
+    }
+  }
+
+  _onCaptureFocus(e: Event) {
+    const hovercardContainer = getHovercardContainer();
+    if (hovercardContainer) {
+      // Hovercard container is not a child of an overlay.
+      // When an overlay is opened and a user clicks inside hovercard,
+      // the IronOverlayBehavior doesn't allow to set focus inside a hovercard.
+      // As a result, user can't select a text (username) in the hovercard
+      // in a dialog. We should skip default _onCaptureFocus for hovercards.
+      const path = e.composedPath();
+      if (path.indexOf(hovercardContainer) >= 0) return;
+    }
+    super._onCaptureFocus(e);
   }
 
   /**
@@ -137,7 +147,7 @@
   _awaitOpen(fn: (this: GrOverlay) => void, reject: (error: Error) => void) {
     let iters = 0;
     const step = () => {
-      this.async(() => {
+      setTimeout(() => {
         if (this.style.display !== 'none') {
           fn.call(this);
         } else if (iters++ < AWAIT_MAX_ITERS) {
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
index 4b6ae34..72c3399 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
@@ -57,11 +57,11 @@
     await element.open();
 
     assert.isTrue(element._isMobile.called);
-    assert.isTrue(element._fullScreenOpen);
+    assert.isTrue(element.fullScreenOpen);
     assert.isTrue(openHandler.called);
 
     element._overlayClosed();
-    assert.isFalse(element._fullScreenOpen);
+    assert.isFalse(element.fullScreenOpen);
     assert.isTrue(closeHandler.called);
   });
 
@@ -75,11 +75,11 @@
     await element.open();
 
     assert.isTrue(element._isMobile.called);
-    assert.isFalse(element._fullScreenOpen);
+    assert.isFalse(element.fullScreenOpen);
     assert.isFalse(openHandler.called);
 
     element._overlayClosed();
-    assert.isFalse(element._fullScreenOpen);
+    assert.isFalse(element.fullScreenOpen);
     assert.isFalse(closeHandler.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
index e009499..9a9dc03 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
@@ -15,8 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-page-nav_html';
 import {customElement, property} from '@polymer/decorators';
@@ -34,9 +32,7 @@
 }
 
 @customElement('gr-page-nav')
-export class GrPageNav extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrPageNav extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -51,14 +47,14 @@
     this.bodyScrollHandler = () => this._handleBodyScroll();
   }
 
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     window.addEventListener('scroll', this.bodyScrollHandler);
   }
 
-  detached() {
-    super.detached();
+  disconnectedCallback() {
     window.removeEventListener('scroll', this.bodyScrollHandler);
+    super.disconnectedCallback();
   }
 
   _handleBodyScroll() {
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts
index facc4f8..a9d9216 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts
@@ -36,7 +36,7 @@
       }
     }
   </style>
-  <nav id="nav">
+  <nav id="nav" aria-label="Sidebar">
     <slot></slot>
   </nav>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
index 01eada8..31566a8 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
@@ -18,9 +18,6 @@
 import '../../../styles/shared-styles';
 import '../gr-icons/gr-icons';
 import '../gr-labeled-autocomplete/gr-labeled-autocomplete';
-import '../gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-branch-picker_html';
 import {singleDecodeURL} from '../../../utils/url-util';
@@ -33,7 +30,7 @@
   BranchInfo,
 } from '../../../types/common';
 import {GrLabeledAutocomplete} from '../gr-labeled-autocomplete/gr-labeled-autocomplete';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
@@ -42,13 +39,10 @@
   $: {
     repoInput: GrLabeledAutocomplete;
     branchInput: GrLabeledAutocomplete;
-    restAPI: RestApiService & Element;
   };
 }
 @customElement('gr-repo-branch-picker')
-export class GrRepoBranchPicker extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRepoBranchPicker extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -68,6 +62,8 @@
   @property({type: Object})
   _repoQuery?: AutocompleteQuery;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = input => this._getRepoBranchesSuggestions(input);
@@ -75,8 +71,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     if (this.repo) {
       this.$.repoInput.setText(this.repo);
     }
@@ -95,13 +91,13 @@
     if (input.startsWith(REF_PREFIX)) {
       input = input.substring(REF_PREFIX.length);
     }
-    return this.$.restAPI
+    return this.restApiService
       .getRepoBranches(input, this.repo, SUGGESTIONS_LIMIT)
       .then(res => this._branchResponseToSuggestions(res));
   }
 
   _getRepoSuggestions(input: string) {
-    return this.$.restAPI
+    return this.restApiService
       .getRepos(input, SUGGESTIONS_LIMIT)
       .then(res => this._repoResponseToSuggestions(res));
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts
index 934b3cb..3e551b6 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts
@@ -49,5 +49,4 @@
     >
     </gr-labeled-autocomplete>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js
index 1d8ae98..4f12edc 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-repo-branch-picker.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-branch-picker');
 
@@ -28,8 +29,9 @@
   });
 
   suite('_getRepoSuggestions', () => {
+    let getReposStub;
     setup(() => {
-      sinon.stub(element.$.restAPI, 'getRepos')
+      getReposStub = stubRestApi('getRepos')
           .returns(Promise.resolve([
             {
               id: 'plugins%2Favatars-external',
@@ -47,25 +49,25 @@
           ]));
     });
 
-    test('converts to suggestion objects', () => {
+    test('converts to suggestion objects', async () => {
       const input = 'plugins/avatars';
-      return element._getRepoSuggestions(input).then(suggestions => {
-        assert.isTrue(element.$.restAPI.getRepos.calledWith(input));
-        const unencodedNames = [
-          'plugins/avatars-external',
-          'plugins/avatars-gravatar',
-          'plugins/avatars/external',
-          'plugins/avatars/gravatar',
-        ];
-        assert.deepEqual(suggestions.map(s => s.name), unencodedNames);
-        assert.deepEqual(suggestions.map(s => s.value), unencodedNames);
-      });
+      const suggestions = await element._getRepoSuggestions(input);
+      assert.isTrue(getReposStub.calledWith(input));
+      const unencodedNames = [
+        'plugins/avatars-external',
+        'plugins/avatars-gravatar',
+        'plugins/avatars/external',
+        'plugins/avatars/gravatar',
+      ];
+      assert.deepEqual(suggestions.map(s => s.name), unencodedNames);
+      assert.deepEqual(suggestions.map(s => s.value), unencodedNames);
     });
   });
 
   suite('_getRepoBranchesSuggestions', () => {
+    let getRepoBranchesStub;
     setup(() => {
-      sinon.stub(element.$.restAPI, 'getRepoBranches')
+      getRepoBranchesStub = stubRestApi('getRepoBranches')
           .returns(Promise.resolve([
             {ref: 'refs/heads/stable-2.10'},
             {ref: 'refs/heads/stable-2.11'},
@@ -76,48 +78,43 @@
           ]));
     });
 
-    test('converts to suggestion objects', () => {
+    test('converts to suggestion objects', async () => {
       const repo = 'gerrit';
       const branchInput = 'stable-2.1';
       element.repo = repo;
-      return element._getRepoBranchesSuggestions(branchInput)
-          .then(suggestions => {
-            assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
-                branchInput, repo, 15));
-            const refNames = [
-              'stable-2.10',
-              'stable-2.11',
-              'stable-2.12',
-              'stable-2.13',
-              'stable-2.14',
-              'stable-2.15',
-            ];
-            assert.deepEqual(suggestions.map(s => s.name), refNames);
-            assert.deepEqual(suggestions.map(s => s.value), refNames);
-          });
+      const suggestions =
+          await element._getRepoBranchesSuggestions(branchInput);
+      assert.isTrue(getRepoBranchesStub.calledWith(branchInput, repo, 15));
+      const refNames = [
+        'stable-2.10',
+        'stable-2.11',
+        'stable-2.12',
+        'stable-2.13',
+        'stable-2.14',
+        'stable-2.15',
+      ];
+      assert.deepEqual(suggestions.map(s => s.name), refNames);
+      assert.deepEqual(suggestions.map(s => s.value), refNames);
     });
 
-    test('filters out ref prefix', () => {
+    test('filters out ref prefix', async () => {
       const repo = 'gerrit';
       const branchInput = 'refs/heads/stable-2.1';
       element.repo = repo;
       return element._getRepoBranchesSuggestions(branchInput)
           .then(suggestions => {
-            assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
+            assert.isTrue(getRepoBranchesStub.calledWith(
                 'stable-2.1', repo, 15));
           });
     });
 
-    test('does not query when repo is unset', () => element
-        ._getRepoBranchesSuggestions('')
-        .then(() => {
-          assert.isFalse(element.$.restAPI.getRepoBranches.called);
-          element.repo = 'gerrit';
-          return element._getRepoBranchesSuggestions('');
-        })
-        .then(() => {
-          assert.isTrue(element.$.restAPI.getRepoBranches.called);
-        }));
+    test('does not query when repo is unset', async () => {
+      await element._getRepoBranchesSuggestions('');
+      assert.isFalse(getRepoBranchesStub.called);
+      element.repo = 'gerrit';
+      await element._getRepoBranchesSuggestions('');
+      assert.isTrue(getRepoBranchesStub.called);
+    });
   });
 });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 7a39838..5abc258 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -16,8 +16,6 @@
  */
 /* NB: Order is important, because of namespaced classes. */
 
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {GrEtagDecorator} from './gr-etag-decorator';
 import {
@@ -25,28 +23,23 @@
   FetchParams,
   FetchPromisesCache,
   GrRestApiHelper,
+  parsePrefixedJSON,
+  readResponsePayload,
   SendJSONRequest,
   SendRequest,
   SiteBasedCache,
 } from './gr-rest-apis/gr-rest-api-helper';
-import {
-  GrReviewerUpdatesParser,
-  ParsedChangeInfo,
-} from './gr-reviewer-updates-parser';
+import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser';
 import {parseDate} from '../../../utils/date-util';
 import {getBaseUrl} from '../../../utils/url-util';
 import {appContext} from '../../../services/app-context';
-import {
-  getParentIndex,
-  isMergeParent,
-  patchNumEquals,
-} from '../../../utils/patch-set-util';
+import {getParentIndex, isMergeParent} from '../../../utils/patch-set-util';
 import {
   ListChangesOption,
   listChangesOptionsToHex,
 } from '../../../utils/change-util';
 import {assertNever, hasOwnProperty} from '../../../utils/common-util';
-import {customElement, property} from '@polymer/decorators';
+import {customElement} from '@polymer/decorators';
 import {AuthRequestInit, AuthService} from '../../../services/gr-auth/gr-auth';
 import {
   AccountCapabilityInfo,
@@ -54,109 +47,116 @@
   AccountExternalIdInfo,
   AccountId,
   AccountInfo,
+  ActionNameToActionInfoMap,
   AssigneeInput,
   Base64File,
   Base64FileContent,
   Base64ImageFile,
+  BasePatchSetNum,
+  BlameInfo,
   BranchInfo,
+  BranchInput,
   BranchName,
+  CapabilityInfoMap,
   ChangeId,
   ChangeInfo,
   ChangeMessageId,
+  ChangeViewChangeInfo,
   CommentInfo,
   CommentInput,
   CommitId,
   CommitInfo,
   ConfigInfo,
   ConfigInput,
+  ContributorAgreementInfo,
+  ContributorAgreementInput,
   DashboardId,
   DashboardInfo,
   DeleteDraftCommentsInput,
-  DiffInfo,
   DiffPreferenceInput,
-  DiffPreferencesInfo,
+  DocResult,
+  EditInfo,
   EditPatchSetNum,
   EditPreferencesInfo,
+  EmailAddress,
+  EmailInfo,
   EncodedGroupId,
+  FileNameToFileInfoMap,
+  FilePathToDiffInfoMap,
+  FixId,
   GitRef,
   GpgKeyId,
+  GpgKeyInfo,
+  GpgKeysInput,
+  GroupAuditEventInfo,
   GroupId,
   GroupInfo,
   GroupInput,
+  GroupName,
+  GroupNameToGroupInfoMap,
   GroupOptionsInput,
+  Hashtag,
   HashtagsInput,
   ImagesForDiff,
+  IncludedInInfo,
+  MergeableInfo,
   NameToProjectInfoMap,
+  NumericChangeId,
   ParentPatchSetNum,
   ParsedJSON,
+  Password,
   PatchRange,
   PatchSetNum,
   PathToCommentsInfoMap,
   PathToRobotCommentsInfoMap,
+  PluginInfo,
   PreferencesInfo,
   PreferencesInput,
+  ProjectAccessInfo,
   ProjectAccessInfoMap,
   ProjectAccessInput,
   ProjectInfo,
+  ProjectInfoWithName,
   ProjectInput,
   ProjectWatchInfo,
+  RelatedChangesInfo,
   RepoName,
+  RequestPayload,
   ReviewInput,
+  RevisionId,
   ServerInfo,
   SshKeyInfo,
-  UrlEncodedCommentId,
-  EditInfo,
-  FileNameToFileInfoMap,
-  SuggestedReviewerInfo,
-  GroupNameToGroupInfoMap,
-  GroupAuditEventInfo,
-  RequestPayload,
-  Password,
-  ContributorAgreementInput,
-  ContributorAgreementInfo,
-  BranchInput,
-  IncludedInInfo,
-  TagInput,
-  PluginInfo,
-  GpgKeyInfo,
-  GpgKeysInput,
-  DocResult,
-  EmailInfo,
-  ProjectAccessInfo,
-  CapabilityInfoMap,
-  ProjectInfoWithName,
-  TagInfo,
-  RelatedChangesInfo,
   SubmittedTogetherInfo,
-  NumericChangeId,
-  EmailAddress,
-  FixId,
-  FilePathToDiffInfoMap,
-  ChangeViewChangeInfo,
-  BlameInfo,
-  ActionNameToActionInfoMap,
-  RevisionId,
-  GroupName,
-  Hashtag,
+  SuggestedReviewerInfo,
+  TagInfo,
+  TagInput,
   TopMenuEntryInfo,
-  MergeableInfo,
+  UrlEncodedCommentId,
 } from '../../../types/common';
 import {
+  DiffInfo,
+  DiffPreferencesInfo,
+  IgnoreWhitespaceType,
+} from '../../../types/diff';
+import {
   CancelConditionCallback,
-  ErrorCallback,
-  RestApiService,
   GetDiffCommentsOutput,
   GetDiffRobotCommentsOutput,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
+  RestApiService,
+} from '../../../services/gr-rest-api/gr-rest-api';
 import {
   CommentSide,
+  createDefaultDiffPrefs,
+  createDefaultEditPrefs,
+  createDefaultPreferences,
   DiffViewMode,
   HttpMethod,
-  IgnoreWhitespaceType,
   ReviewerState,
 } from '../../../constants/constants';
+import {firePageError, fireServerError} from '../../../utils/event-util';
+import {ParsedChangeInfo} from '../../../types/types';
+import {ErrorCallback} from '../../../api/rest';
 
-const JSON_PREFIX = ")]}'";
 const MAX_PROJECT_RESULTS = 25;
 // This value is somewhat arbitrary and not based on research or calculations.
 const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
@@ -252,7 +252,6 @@
 
 interface GetDiffParams {
   [paramName: string]: string | undefined | null | number | boolean;
-  context?: number | 'ALL';
   intraline?: boolean | null;
   whitespace?: IgnoreWhitespaceType;
   parent?: number;
@@ -262,27 +261,6 @@
 type SendChangeRequest = SendRawChangeRequest | SendJSONChangeRequest;
 
 export function _testOnlyResetGrRestApiSharedObjects() {
-  // TODO(TS): The commented code below didn't do anything.
-  // It is impossible to reject an existing promise. Should be rewritten in a
-  // different way
-  // const fetchPromisesCacheData = fetchPromisesCache.testOnlyGetData();
-  // for (const key in fetchPromisesCacheData) {
-  //   if (hasOwnProperty(fetchPromisesCacheData, key)) {
-  //     // reject already fulfilled promise does nothing
-  //     fetchPromisesCacheData[key]!.reject();
-  //   }
-  // }
-  //
-  // for (const key in pendingRequest) {
-  //   if (!hasOwnProperty(pendingRequest, key)) {
-  //     continue;
-  //   }
-  //   for (const req of pendingRequest[key]) {
-  //     // reject already fulfilled promise does nothing
-  //     req.reject();
-  //   }
-  // }
-
   siteBasedCache = new SiteBasedCache();
   fetchPromisesCache = new FetchPromisesCache();
   pendingRequest = {};
@@ -299,40 +277,16 @@
 
 @customElement('gr-rest-api-interface')
 export class GrRestApiInterface
-  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+  extends PolymerElement
   implements RestApiService {
-  readonly JSON_PREFIX = JSON_PREFIX;
-  /**
-   * Fired when an server error occurs.
-   *
-   * @event server-error
-   */
-
-  /**
-   * Fired when a network error occurs.
-   *
-   * @event network-error
-   */
-
-  /**
-   * Fired after an RPC completes.
-   *
-   * @event rpc-log
-   */
-
-  @property({type: Object})
   readonly _cache = siteBasedCache; // Shared across instances.
 
-  @property({type: Object})
   readonly _sharedFetchPromises = fetchPromisesCache; // Shared across instances.
 
-  @property({type: Object})
   readonly _pendingRequests = pendingRequest; // Shared across instances.
 
-  @property({type: Object})
   readonly _etags = grEtagDecorator; // Shared across instances.
 
-  @property({type: Object})
   readonly _projectLookup = projectLookup; // Shared across instances.
 
   // The value is set in created, before any other actions
@@ -341,14 +295,15 @@
   // The value is set in created, before any other actions
   private readonly _restApiHelper: GrRestApiHelper;
 
-  constructor() {
+  constructor(authService?: AuthService) {
     super();
-    this.authService = appContext.authService;
+    // TODO: Make the authService constructor parameter required when we have
+    // changed all usages of this class to not instantiate via createElement().
+    this.authService = authService ?? appContext.authService;
     this._restApiHelper = new GrRestApiHelper(
       this._cache,
       this.authService,
-      this._sharedFetchPromises,
-      this
+      this._sharedFetchPromises
     );
   }
 
@@ -423,19 +378,7 @@
     }) as Promise<DashboardInfo[] | undefined>;
   }
 
-  saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response>;
-
-  saveRepoConfig(
-    repo: RepoName,
-    config: ConfigInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  saveRepoConfig(
-    repo: RepoName,
-    config: ConfigInput,
-    errFn?: ErrorCallback
-  ): Promise<Response | undefined> {
+  saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response> {
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
     const url = `/projects/${encodeURIComponent(repo)}/config`;
@@ -444,23 +387,11 @@
       method: HttpMethod.PUT,
       url,
       body: config,
-      errFn,
       anonymizedUrl: '/projects/*/config',
     });
   }
 
-  runRepoGC(repo: RepoName): Promise<Response>;
-
-  runRepoGC(
-    repo: RepoName,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  runRepoGC(repo: RepoName, errFn?: ErrorCallback) {
-    if (!repo) {
-      // TODO(TS): fix return value
-      return '';
-    }
+  runRepoGC(repo: RepoName): Promise<Response> {
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
     const encodeName = encodeURIComponent(repo);
@@ -468,23 +399,11 @@
       method: HttpMethod.POST,
       url: `/projects/${encodeName}/gc`,
       body: '',
-      errFn,
       anonymizedUrl: '/projects/*/gc',
     });
   }
 
-  createRepo(config: ProjectInput & {name: RepoName}): Promise<Response>;
-
-  createRepo(
-    config: ProjectInput & {name: RepoName},
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  createRepo(config: ProjectInput, errFn?: ErrorCallback) {
-    if (!config.name) {
-      // TODO(TS): Fix return value
-      return '';
-    }
+  createRepo(config: ProjectInput & {name: RepoName}): Promise<Response> {
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
     const encodeName = encodeURIComponent(config.name);
@@ -492,29 +411,16 @@
       method: HttpMethod.PUT,
       url: `/projects/${encodeName}`,
       body: config,
-      errFn,
       anonymizedUrl: '/projects/*',
     });
   }
 
-  createGroup(config: GroupInput & {name: string}): Promise<Response>;
-
-  createGroup(
-    config: GroupInput & {name: string},
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  createGroup(config: GroupInput, errFn?: ErrorCallback) {
-    if (!config.name) {
-      // TODO(TS): Fix return value
-      return '';
-    }
+  createGroup(config: GroupInput & {name: string}): Promise<Response> {
     const encodeName = encodeURIComponent(config.name);
     return this._restApiHelper.send({
       method: HttpMethod.PUT,
       url: `/groups/${encodeName}`,
       body: config,
-      errFn,
       anonymizedUrl: '/groups/*',
     });
   }
@@ -530,19 +436,7 @@
     }) as Promise<GroupInfo | undefined>;
   }
 
-  deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response>;
-
-  deleteRepoBranches(
-    repo: RepoName,
-    ref: GitRef,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  deleteRepoBranches(repo: RepoName, ref: GitRef, errFn?: ErrorCallback) {
-    if (!repo || !ref) {
-      // TODO(TS): fix return value
-      return '';
-    }
+  deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response> {
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
     const encodeName = encodeURIComponent(repo);
@@ -551,24 +445,11 @@
       method: HttpMethod.DELETE,
       url: `/projects/${encodeName}/branches/${encodeRef}`,
       body: '',
-      errFn,
       anonymizedUrl: '/projects/*/branches/*',
     });
   }
 
-  deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response>;
-
-  deleteRepoTags(
-    repo: RepoName,
-    ref: GitRef,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  deleteRepoTags(repo: RepoName, ref: GitRef, errFn?: ErrorCallback) {
-    if (!repo || !ref) {
-      // TODO(TS): fix return type
-      return '';
-    }
+  deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response> {
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
     const encodeName = encodeURIComponent(repo);
@@ -577,7 +458,6 @@
       method: HttpMethod.DELETE,
       url: `/projects/${encodeName}/tags/${encodeRef}`,
       body: '',
-      errFn,
       anonymizedUrl: '/projects/*/tags/*',
     });
   }
@@ -586,25 +466,7 @@
     name: RepoName,
     branch: BranchName,
     revision: BranchInput
-  ): Promise<Response>;
-
-  createRepoBranch(
-    name: RepoName,
-    branch: BranchName,
-    revision: BranchInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  createRepoBranch(
-    name: RepoName,
-    branch: BranchName,
-    revision: BranchInput,
-    errFn?: ErrorCallback
-  ) {
-    if (!name || !branch || !revision) {
-      // TODO(TS) fix return type
-      return '';
-    }
+  ): Promise<Response> {
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
     const encodeName = encodeURIComponent(name);
@@ -613,7 +475,6 @@
       method: HttpMethod.PUT,
       url: `/projects/${encodeName}/branches/${encodeBranch}`,
       body: revision,
-      errFn,
       anonymizedUrl: '/projects/*/branches/*',
     });
   }
@@ -622,25 +483,7 @@
     name: RepoName,
     tag: string,
     revision: TagInput
-  ): Promise<Response>;
-
-  createRepoTag(
-    name: RepoName,
-    tag: string,
-    revision: TagInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  createRepoTag(
-    name: RepoName,
-    tag: string,
-    revision: TagInput,
-    errFn?: ErrorCallback
-  ) {
-    if (!name || !tag || !revision) {
-      // TODO(TS): Fix return value
-      return '';
-    }
+  ): Promise<Response> {
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
     const encodeName = encodeURIComponent(name);
@@ -649,7 +492,6 @@
       method: HttpMethod.PUT,
       url: `/projects/${encodeName}/tags/${encodeTag}`,
       body: revision,
-      errFn,
       anonymizedUrl: '/projects/*/tags/*',
     });
   }
@@ -665,16 +507,12 @@
     );
   }
 
-  getGroupMembers(
-    groupName: GroupId | GroupName,
-    errFn?: ErrorCallback
-  ): Promise<AccountInfo[] | undefined> {
+  getGroupMembers(groupName: GroupId | GroupName): Promise<AccountInfo[]> {
     const encodeName = encodeURIComponent(groupName);
-    return this._restApiHelper.fetchJSON({
+    return (this._restApiHelper.fetchJSON({
       url: `/groups/${encodeName}/members/`,
-      errFn,
       anonymizedUrl: '/groups/*/members',
-    }) as Promise<AccountInfo[] | undefined>;
+    }) as unknown) as Promise<AccountInfo[]>;
   }
 
   getIncludedGroup(
@@ -775,9 +613,9 @@
     };
     return this._restApiHelper.send(req).then(response => {
       if (response?.ok) {
-        return (this.getResponseObject(response) as unknown) as Promise<
-          GroupInfo
-        >;
+        return (this.getResponseObject(
+          response
+        ) as unknown) as Promise<GroupInfo>;
       }
       return undefined;
     });
@@ -824,27 +662,7 @@
           reportUrlAsIs: true,
         }) as Promise<DiffPreferencesInfo | undefined>;
       }
-      const anonymousResult: DiffPreferencesInfo = {
-        auto_hide_diff_table_header: true,
-        context: 10,
-        cursor_blink_rate: 0,
-        font_size: 12,
-        ignore_whitespace: IgnoreWhitespaceType.IGNORE_NONE,
-        intraline_difference: true,
-        line_length: 100,
-        line_wrapping: false,
-        show_line_endings: true,
-        show_tabs: true,
-        show_whitespace_errors: true,
-        syntax_highlighting: true,
-        tab_size: 8,
-        theme: 'DEFAULT',
-      };
-      // These defaults should match the defaults in
-      // java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
-      // NOTE: There are some settings that don't apply to PolyGerrit
-      // (Render mode being at least one of them).
-      return Promise.resolve(anonymousResult);
+      return Promise.resolve(createDefaultDiffPrefs());
     });
   }
 
@@ -856,38 +674,11 @@
           reportUrlAsIs: true,
         }) as Promise<EditPreferencesInfo | undefined>;
       }
-      const result: EditPreferencesInfo = {
-        auto_close_brackets: false,
-        cursor_blink_rate: 0,
-        hide_line_numbers: false,
-        hide_top_menu: false,
-        indent_unit: 2,
-        indent_with_tabs: false,
-        key_map_type: 'DEFAULT',
-        line_length: 100,
-        line_wrapping: false,
-        match_brackets: true,
-        show_base: false,
-        show_tabs: true,
-        show_whitespace_errors: true,
-        syntax_highlighting: true,
-        tab_size: 8,
-        theme: 'DEFAULT',
-      };
-      // These defaults should match the defaults in
-      // java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
-      return Promise.resolve(result);
+      return Promise.resolve(createDefaultEditPrefs());
     });
   }
 
-  savePreferences(prefs: PreferencesInput): Promise<Response>;
-
-  savePreferences(
-    prefs: PreferencesInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  savePreferences(prefs: PreferencesInput, errFn?: ErrorCallback) {
+  savePreferences(prefs: PreferencesInput): Promise<Response> {
     // Note (Issue 5142): normalize the download scheme with lower case before
     // saving.
     if (prefs.download_scheme) {
@@ -898,45 +689,28 @@
       method: HttpMethod.PUT,
       url: '/accounts/self/preferences',
       body: prefs,
-      errFn,
       reportUrlAsIs: true,
     });
   }
 
-  saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response>;
-
-  saveDiffPreferences(
-    prefs: DiffPreferenceInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  saveDiffPreferences(prefs: DiffPreferenceInput, errFn?: ErrorCallback) {
+  saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response> {
     // Invalidate the cache.
     this._cache.delete('/accounts/self/preferences.diff');
     return this._restApiHelper.send({
       method: HttpMethod.PUT,
       url: '/accounts/self/preferences.diff',
       body: prefs,
-      errFn,
       reportUrlAsIs: true,
     });
   }
 
-  saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response>;
-
-  saveEditPreferences(
-    prefs: EditPreferencesInfo,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  saveEditPreferences(prefs: EditPreferencesInfo, errFn?: ErrorCallback) {
+  saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response> {
     // Invalidate the cache.
     this._cache.delete('/accounts/self/preferences.edit');
     return this._restApiHelper.send({
       method: HttpMethod.PUT,
       url: '/accounts/self/preferences.edit',
       body: prefs,
-      errFn,
       reportUrlAsIs: true,
     });
   }
@@ -996,48 +770,28 @@
     }) as Promise<EmailInfo[] | undefined>;
   }
 
-  addAccountEmail(email: string): Promise<Response>;
-
-  addAccountEmail(
-    email: string,
-    errFn?: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  addAccountEmail(email: string, errFn?: ErrorCallback) {
+  addAccountEmail(email: string): Promise<Response> {
     return this._restApiHelper.send({
       method: HttpMethod.PUT,
       url: '/accounts/self/emails/' + encodeURIComponent(email),
-      errFn,
       anonymizedUrl: '/account/self/emails/*',
     });
   }
 
-  deleteAccountEmail(email: string): Promise<Response>;
-
-  deleteAccountEmail(
-    email: string,
-    errFn?: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  deleteAccountEmail(email: string, errFn?: ErrorCallback) {
+  deleteAccountEmail(email: string): Promise<Response> {
     return this._restApiHelper.send({
       method: HttpMethod.DELETE,
       url: '/accounts/self/emails/' + encodeURIComponent(email),
-      errFn,
       anonymizedUrl: '/accounts/self/email/*',
     });
   }
 
-  setPreferredAccountEmail(
-    email: string,
-    errFn?: ErrorCallback
-  ): Promise<void> {
+  setPreferredAccountEmail(email: string): Promise<void> {
     // TODO(TS): add correct error handling
     const encodedEmail = encodeURIComponent(email);
     const req = {
       method: HttpMethod.PUT,
       url: `/accounts/self/emails/${encodedEmail}/preferred`,
-      errFn,
       anonymizedUrl: '/accounts/self/emails/*/preferred',
     };
     return this._restApiHelper.send(req).then(() => {
@@ -1067,13 +821,12 @@
     }
   }
 
-  setAccountName(name: string, errFn?: ErrorCallback): Promise<void> {
+  setAccountName(name: string): Promise<void> {
     // TODO(TS): add correct error handling
     const req: SendJSONRequest = {
       method: HttpMethod.PUT,
       url: '/accounts/self/name',
       body: {name},
-      errFn,
       parseResponse: true,
       reportUrlAsIs: true,
     };
@@ -1084,13 +837,12 @@
       );
   }
 
-  setAccountUsername(username: string, errFn?: ErrorCallback): Promise<void> {
+  setAccountUsername(username: string): Promise<void> {
     // TODO(TS): add correct error handling
     const req: SendJSONRequest = {
       method: HttpMethod.PUT,
       url: '/accounts/self/username',
       body: {username},
-      errFn,
       parseResponse: true,
       reportUrlAsIs: true,
     };
@@ -1101,16 +853,12 @@
       );
   }
 
-  setAccountDisplayName(
-    displayName: string,
-    errFn?: ErrorCallback
-  ): Promise<void> {
+  setAccountDisplayName(displayName: string): Promise<void> {
     // TODO(TS): add correct error handling
     const req: SendJSONRequest = {
       method: HttpMethod.PUT,
       url: '/accounts/self/displayname',
       body: {display_name: displayName},
-      errFn,
       parseResponse: true,
       reportUrlAsIs: true,
     };
@@ -1121,13 +869,12 @@
     );
   }
 
-  setAccountStatus(status: string, errFn?: ErrorCallback): Promise<void> {
+  setAccountStatus(status: string): Promise<void> {
     // TODO(TS): add correct error handling
     const req: SendJSONRequest = {
       method: HttpMethod.PUT,
       url: '/accounts/self/status',
       body: {status},
-      errFn,
       parseResponse: true,
       reportUrlAsIs: true,
     };
@@ -1228,19 +975,7 @@
           return prefInfo;
         });
       }
-
-      // TODO(TS): Many properties are omitted here, but they are required.
-      // Add default values for missed properties
-      const anonymousPrefs = {
-        changes_per_page: 25,
-        default_diff_view: this._isNarrowScreen()
-          ? DiffViewMode.UNIFIED
-          : DiffViewMode.SIDE_BY_SIDE,
-        diff_view: DiffViewMode.SIDE_BY_SIDE,
-        size_bar_in_change_table: true,
-      } as PreferencesInfo;
-
-      return anonymousPrefs;
+      return createDefaultPreferences();
     });
   }
 
@@ -1252,34 +987,22 @@
   }
 
   saveWatchedProjects(
-    projects: ProjectWatchInfo[],
-    errFn?: ErrorCallback
+    projects: ProjectWatchInfo[]
   ): Promise<ProjectWatchInfo[]> {
     return (this._restApiHelper.send({
       method: HttpMethod.POST,
       url: '/accounts/self/watched.projects',
       body: projects,
-      errFn,
       parseResponse: true,
       reportUrlAsIs: true,
     }) as unknown) as Promise<ProjectWatchInfo[]>;
   }
 
-  deleteWatchedProjects(
-    projects: ProjectWatchInfo[]
-  ): Promise<Response | undefined>;
-
-  deleteWatchedProjects(
-    projects: ProjectWatchInfo[],
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  deleteWatchedProjects(projects: ProjectWatchInfo[], errFn?: ErrorCallback) {
+  deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise<Response> {
     return this._restApiHelper.send({
       method: HttpMethod.POST,
       url: '/accounts/self/watched.projects:delete',
       body: projects,
-      errFn,
       reportUrlAsIs: true,
     });
   }
@@ -1458,11 +1181,7 @@
     return listChangesOptionsToHex(...options);
   }
 
-  getDiffChangeDetail(
-    changeNum: NumericChangeId,
-    errFn?: ErrorCallback,
-    cancelCondition?: CancelConditionCallback
-  ) {
+  getDiffChangeDetail(changeNum: NumericChangeId) {
     let optionsHex = '';
     if (window.DEFAULT_DETAIL_HEXES?.diffPage) {
       optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage;
@@ -1473,7 +1192,7 @@
         ListChangesOption.SKIP_DIFFSTAT
       );
     }
-    return this._getChangeDetail(changeNum, optionsHex, errFn, cancelCondition);
+    return this._getChangeDetail(changeNum, optionsHex);
   }
 
   /**
@@ -1499,7 +1218,7 @@
         };
         return this._restApiHelper.fetchRawJSON(req).then(response => {
           if (response?.status === 304) {
-            return (this._restApiHelper.parsePrefixedJSON(
+            return (parsePrefixedJSON(
               // urlWithParams already cached
               this._etags.getCachedPayload(urlWithParams)!
             ) as unknown) as ChangeInfo;
@@ -1509,13 +1228,7 @@
             if (errFn) {
               errFn.call(null, response);
             } else {
-              this.dispatchEvent(
-                new CustomEvent('server-error', {
-                  detail: {request: req, response},
-                  composed: true,
-                  bubbles: true,
-                })
-              );
+              fireServerError(response, req);
             }
             return undefined;
           }
@@ -1524,20 +1237,18 @@
             return Promise.resolve(null);
           }
 
-          return this._restApiHelper
-            .readResponsePayload(response)
-            .then(payload => {
-              if (!payload) {
-                return null;
-              }
-              this._etags.collect(urlWithParams, response, payload.raw);
-              // TODO(TS): Why it is always change info?
-              this._maybeInsertInLookup(
-                (payload.parsed as unknown) as ChangeInfo
-              );
+          return readResponsePayload(response).then(payload => {
+            if (!payload) {
+              return null;
+            }
+            this._etags.collect(urlWithParams, response, payload.raw);
+            // TODO(TS): Why it is always change info?
+            this._maybeInsertInLookup(
+              (payload.parsed as unknown) as ChangeInfo
+            );
 
-              return (payload.parsed as unknown) as ChangeInfo;
-            });
+            return (payload.parsed as unknown) as ChangeInfo;
+          });
         });
       }
     );
@@ -1559,7 +1270,7 @@
     let params = undefined;
     if (isMergeParent(patchRange.basePatchNum)) {
       params = {parent: getParentIndex(patchRange.basePatchNum)};
-    } else if (!patchNumEquals(patchRange.basePatchNum, ParentPatchSetNum)) {
+    } else if (patchRange.basePatchNum !== ParentPatchSetNum) {
       params = {base: patchRange.basePatchNum};
     }
     return this._getChangeURLAndFetch({
@@ -1606,7 +1317,7 @@
     changeNum: NumericChangeId,
     patchRange: PatchRange
   ): Promise<FileNameToFileInfoMap | undefined> {
-    if (patchNumEquals(patchRange.patchNum, EditPatchSetNum)) {
+    if (patchRange.patchNum === EditPatchSetNum) {
       return this.getChangeEditFiles(changeNum, patchRange).then(
         res => res && res.files
       );
@@ -1629,37 +1340,22 @@
     >;
   }
 
-  getChangeSuggestedReviewers(
-    changeNum: NumericChangeId,
-    inputVal: string,
-    errFn?: ErrorCallback
-  ) {
+  getChangeSuggestedReviewers(changeNum: NumericChangeId, inputVal: string) {
     return this._getChangeSuggestedGroup(
       ReviewerState.REVIEWER,
       changeNum,
-      inputVal,
-      errFn
+      inputVal
     );
   }
 
-  getChangeSuggestedCCs(
-    changeNum: NumericChangeId,
-    inputVal: string,
-    errFn?: ErrorCallback
-  ) {
-    return this._getChangeSuggestedGroup(
-      ReviewerState.CC,
-      changeNum,
-      inputVal,
-      errFn
-    );
+  getChangeSuggestedCCs(changeNum: NumericChangeId, inputVal: string) {
+    return this._getChangeSuggestedGroup(ReviewerState.CC, changeNum, inputVal);
   }
 
   _getChangeSuggestedGroup(
     reviewerState: ReviewerState,
     changeNum: NumericChangeId,
-    inputVal: string,
-    errFn?: ErrorCallback
+    inputVal: string
   ): Promise<SuggestedReviewerInfo[] | undefined> {
     // More suggestions may obscure content underneath in the reply dialog,
     // see issue 10793.
@@ -1673,7 +1369,6 @@
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: '/suggest_reviewers',
-      errFn,
       params,
       reportEndpointAsIs: true,
     }) as Promise<SuggestedReviewerInfo[] | undefined>;
@@ -1899,8 +1594,7 @@
   getSuggestedGroups(
     inputVal: string,
     project?: RepoName,
-    n?: number,
-    errFn?: ErrorCallback
+    n?: number
   ): Promise<GroupNameToGroupInfoMap | undefined> {
     const params: QueryGroupsParams = {s: inputVal};
     if (n) {
@@ -1911,7 +1605,6 @@
     }
     return this._restApiHelper.fetchJSON({
       url: '/groups/',
-      errFn,
       params,
       reportUrlAsIs: true,
     }) as Promise<GroupNameToGroupInfoMap | undefined>;
@@ -1919,8 +1612,7 @@
 
   getSuggestedProjects(
     inputVal: string,
-    n?: number,
-    errFn?: ErrorCallback
+    n?: number
   ): Promise<NameToProjectInfoMap | undefined> {
     const params = {
       m: inputVal,
@@ -1932,7 +1624,6 @@
     }
     return this._restApiHelper.fetchJSON({
       url: '/projects/',
-      errFn,
       params,
       reportUrlAsIs: true,
     });
@@ -1940,8 +1631,7 @@
 
   getSuggestedAccounts(
     inputVal: string,
-    n?: number,
-    errFn?: ErrorCallback
+    n?: number
   ): Promise<AccountInfo[] | undefined> {
     if (!inputVal) {
       return Promise.resolve([]);
@@ -1952,7 +1642,6 @@
     }
     return this._restApiHelper.fetchJSON({
       url: '/accounts/',
-      errFn,
       params,
       anonymizedUrl: '/accounts/?n=*',
     }) as Promise<AccountInfo[] | undefined>;
@@ -2096,6 +1785,15 @@
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
+  getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined> {
+    const query = [`intopic:"${topic}"`].join(' ');
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params: {q: query},
+      anonymizedUrl: '/changes/intopic:*',
+    }) as Promise<ChangeInfo[] | undefined>;
+  }
+
   getReviewedFiles(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum
@@ -2113,29 +1811,12 @@
     patchNum: PatchSetNum,
     path: string,
     reviewed: boolean
-  ): Promise<Response>;
-
-  saveFileReviewed(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    path: string,
-    reviewed: boolean,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  saveFileReviewed(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    path: string,
-    reviewed: boolean,
-    errFn?: ErrorCallback
-  ) {
+  ): Promise<Response> {
     return this._getChangeURLAndSend({
       changeNum,
       method: reviewed ? HttpMethod.PUT : HttpMethod.DELETE,
       patchNum,
       endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
-      errFn,
       anonymizedEndpoint: '/files/*/reviewed',
     });
   }
@@ -2230,20 +1911,15 @@
     // 404s indicate the file does not exist yet in the revision, so suppress
     // them.
     const suppress404s: ErrorCallback = res => {
-      if (res?.status !== 404) {
-        this.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail: {res},
-            composed: true,
-            bubbles: true,
-          })
-        );
+      if (res && res?.status !== 404) {
+        fireServerError(res);
       }
       return res;
     };
-    const promise = patchNumEquals(patchNum, EditPatchSetNum)
-      ? this._getFileInChangeEdit(changeNum, path)
-      : this._getFileInRevision(changeNum, path, patchNum, suppress404s);
+    const promise =
+      patchNum === EditPatchSetNum
+        ? this._getFileInChangeEdit(changeNum, path)
+        : this._getFileInRevision(changeNum, path, patchNum, suppress404s);
 
     return promise.then(res => {
       if (!res || !res.ok) {
@@ -2530,14 +2206,12 @@
     errFn?: ErrorCallback
   ) {
     const params: GetDiffParams = {
-      context: 'ALL',
       intraline: null,
-      whitespace: whitespace || IgnoreWhitespaceType.IGNORE_NONE,
+      whitespace: whitespace || 'IGNORE_NONE',
     };
     if (isMergeParent(basePatchNum)) {
       params.parent = getParentIndex(basePatchNum);
-    } else if (!patchNumEquals(basePatchNum, ParentPatchSetNum)) {
-      // TODO (TS): fix as PatchSetNum in the condition above
+    } else if (basePatchNum !== ParentPatchSetNum) {
       params.base = basePatchNum;
     }
     const endpoint = `/files/${encodeURIComponent(path)}/diff`;
@@ -2566,23 +2240,27 @@
 
   getDiffComments(
     changeNum: NumericChangeId,
-    basePatchNum: PatchSetNum,
+    basePatchNum: BasePatchSetNum,
     patchNum: PatchSetNum,
     path: string
   ): Promise<GetDiffCommentsOutput>;
 
   getDiffComments(
     changeNum: NumericChangeId,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ) {
     if (!basePatchNum && !patchNum && !path) {
-      return this._getDiffComments(changeNum, '/comments');
+      return this._getDiffComments(changeNum, '/comments', {
+        'enable-context': true,
+        'context-padding': 3,
+      });
     }
     return this._getDiffComments(
       changeNum,
       '/comments',
+      {'enable-context': true, 'context-padding': 3},
       basePatchNum,
       patchNum,
       path
@@ -2595,14 +2273,14 @@
 
   getDiffRobotComments(
     changeNum: NumericChangeId,
-    basePatchNum: PatchSetNum,
+    basePatchNum: BasePatchSetNum,
     patchNum: PatchSetNum,
     path: string
   ): Promise<GetDiffRobotCommentsOutput>;
 
   getDiffRobotComments(
     changeNum: NumericChangeId,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ) {
@@ -2613,6 +2291,7 @@
     return this._getDiffComments(
       changeNum,
       '/robotcomments',
+      undefined,
       basePatchNum,
       patchNum,
       path
@@ -2630,14 +2309,14 @@
 
   getDiffDrafts(
     changeNum: NumericChangeId,
-    basePatchNum: PatchSetNum,
+    basePatchNum: BasePatchSetNum,
     patchNum: PatchSetNum,
     path: string
   ): Promise<GetDiffCommentsOutput>;
 
   getDiffDrafts(
     changeNum: NumericChangeId,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ) {
@@ -2651,6 +2330,7 @@
       return this._getDiffComments(
         changeNum,
         '/drafts',
+        undefined,
         basePatchNum,
         patchNum,
         path
@@ -2683,7 +2363,8 @@
 
   _getDiffComments(
     changeNum: NumericChangeId,
-    endpoint: '/comments' | '/drafts'
+    endpoint: '/comments' | '/drafts',
+    params?: FetchParams
   ): Promise<PathToCommentsInfoMap | undefined>;
 
   _getDiffComments(
@@ -2694,7 +2375,8 @@
   _getDiffComments(
     changeNum: NumericChangeId,
     endpoint: '/comments' | '/drafts',
-    basePatchNum?: PatchSetNum,
+    params?: FetchParams,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ): Promise<GetDiffCommentsOutput>;
@@ -2702,7 +2384,8 @@
   _getDiffComments(
     changeNum: NumericChangeId,
     endpoint: '/robotcomments',
-    basePatchNum?: PatchSetNum,
+    params?: FetchParams,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ): Promise<GetDiffRobotCommentsOutput>;
@@ -2710,7 +2393,8 @@
   _getDiffComments(
     changeNum: NumericChangeId,
     endpoint: string,
-    basePatchNum?: PatchSetNum,
+    params?: FetchParams,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ): Promise<
@@ -2734,6 +2418,7 @@
           endpoint,
           revision: patchNum,
           reportEndpointAsIs: true,
+          params,
         },
         noAcceptHeader
       ) as Promise<
@@ -2802,6 +2487,43 @@
     return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint);
   }
 
+  getPortedComments(
+    changeNum: NumericChangeId,
+    revision: RevisionId
+  ): Promise<PathToCommentsInfoMap | undefined> {
+    // maintaining a custom error function so that errors do not surface in UI
+    const errFn: ErrorCallback = (response?: Response | null) => {
+      if (response)
+        console.info(`Fetching ported comments failed, ${response.status}`);
+    };
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/ported_comments/',
+      revision,
+      errFn,
+    });
+  }
+
+  getPortedDrafts(
+    changeNum: NumericChangeId,
+    revision: RevisionId
+  ): Promise<PathToCommentsInfoMap | undefined> {
+    // maintaining a custom error function so that errors do not surface in UI
+    const errFn: ErrorCallback = (response?: Response | null) => {
+      if (response)
+        console.info(`Fetching ported drafts failed, ${response.status}`);
+    };
+    return this.getLoggedIn().then(loggedIn => {
+      if (!loggedIn) return {};
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/ported_drafts/',
+        revision,
+        errFn,
+      });
+    });
+  }
+
   saveDiffDraft(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
@@ -3054,10 +2776,7 @@
     });
   }
 
-  setChangeTopic(
-    changeNum: NumericChangeId,
-    topic: string | null
-  ): Promise<string> {
+  setChangeTopic(changeNum: NumericChangeId, topic?: string): Promise<string> {
     return (this._getChangeURLAndSend({
       changeNum,
       method: HttpMethod.PUT,
@@ -3121,9 +2840,9 @@
         if (!response || (response.status < 200 && response.status >= 300)) {
           return Promise.reject(new Error('error'));
         }
-        return (this.getResponseObject(response) as unknown) as Promise<
-          SshKeyInfo
-        >;
+        return (this.getResponseObject(
+          response
+        ) as unknown) as Promise<SshKeyInfo>;
       })
       .then(obj => {
         if (!obj || !obj.valid) {
@@ -3228,10 +2947,9 @@
     }) as Promise<CapabilityInfoMap | undefined>;
   }
 
-  getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined> {
+  getTopMenus(): Promise<TopMenuEntryInfo[] | undefined> {
     return this._fetchSharedCacheURL({
       url: '/config/server/top-menus',
-      errFn,
       reportUrlAsIs: true,
     }) as Promise<TopMenuEntryInfo[] | undefined>;
   }
@@ -3285,21 +3003,6 @@
     });
   }
 
-  startReview(
-    changeNum: NumericChangeId,
-    body?: RequestPayload,
-    errFn?: ErrorCallback
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.POST,
-      endpoint: '/ready',
-      body,
-      errFn,
-      reportUrlAsIs: true,
-    });
-  }
-
   deleteComment(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
@@ -3366,16 +3069,7 @@
       return Promise.resolve(project);
     }
 
-    const onError = (response?: Response | null) => {
-      // Fire a page error so that the visual 404 is displayed.
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
-    };
+    const onError = (response?: Response | null) => firePageError(response);
 
     return this.getChange(changeNum, onError).then(change => {
       if (!change || !change.project) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
index 505fd58..7cbcaef 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
@@ -16,13 +16,18 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import './gr-rest-api-interface.js';
-import {mockPromise} from '../../../test/test-utils.js';
+import {addListenerForTest, mockPromise} from '../../../test/test-utils.js';
 import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
 import {ListChangesOption} from '../../../utils/change-util.js';
 import {appContext} from '../../../services/app-context.js';
-
-const basicFixture = fixtureFromElement('gr-rest-api-interface');
+import {createChange} from '../../../test/test-data-generators.js';
+import {CURRENT} from '../../../utils/patch-set-util.js';
+import {
+  parsePrefixedJSON,
+  readResponsePayload,
+} from './gr-rest-apis/gr-rest-api-helper.js';
+import {JSON_PREFIX} from './gr-rest-apis/gr-rest-api-helper.js';
+import {GrRestApiInterface} from './gr-rest-api-interface.js';
 
 suite('gr-rest-api-interface tests', () => {
   let element;
@@ -46,7 +51,7 @@
     // fake auth
     sinon.stub(appContext.authService, 'authCheck')
         .returns(Promise.resolve(true));
-    element = basicFixture.instantiate();
+    element = new GrRestApiInterface();
     element._projectLookup = {};
   });
 
@@ -70,7 +75,8 @@
             },
           ],
         }));
-    return element._getDiffComments('42', '', 'PARENT', 1, 'sieve.go').then(
+    return element._getDiffComments('42', '', undefined, 'PARENT', 1,
+        'sieve.go').then(
         obj => {
           assert.equal(obj.baseComments.length, 1);
           assert.deepEqual(obj.baseComments[0], {
@@ -234,7 +240,7 @@
         });
       }
     });
-    return element._getDiffComments('42', '', 1, 2, 'sieve.go').then(
+    return element._getDiffComments('42', '', undefined, 1, 2, 'sieve.go').then(
         obj => {
           assert.equal(obj.baseComments.length, 1);
           assert.deepEqual(obj.baseComments[0], {
@@ -260,7 +266,7 @@
     const getResponseObjectStub = sinon.stub(element, 'getResponseObject');
     window.fetch.returns(Promise.resolve({ok: false}));
     const serverErrorEventPromise = new Promise(resolve => {
-      element.addEventListener('server-error', resolve);
+      addListenerForTest(document, 'server-error', resolve);
     });
 
     return Promise.all([element._restApiHelper.fetchJSON({}).then(response => {
@@ -351,19 +357,6 @@
         });
       });
 
-  test('getPreferences returns correctly on small screens not logged in',
-      () => {
-        const testJSON = {diff_view: 'SIDE_BY_SIDE'};
-        const loggedIn = false;
-        const smallScreen = true;
-
-        preferenceSetup(testJSON, loggedIn, smallScreen);
-        return element.getPreferences().then(obj => {
-          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-        });
-      });
-
   test('getPreferences returns correctly on larger screens logged in',
       () => {
         const testJSON = {diff_view: 'UNIFIED_DIFF'};
@@ -403,12 +396,10 @@
     sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
 
     return element.getDiffPreferences().then(obj => {
-      assert.equal(obj.auto_hide_diff_table_header, true);
       assert.equal(obj.context, 10);
       assert.equal(obj.cursor_blink_rate, 0);
       assert.equal(obj.font_size, 12);
       assert.equal(obj.ignore_whitespace, 'IGNORE_NONE');
-      assert.equal(obj.intraline_difference, true);
       assert.equal(obj.line_length, 100);
       assert.equal(obj.line_wrapping, false);
       assert.equal(obj.show_line_endings, true);
@@ -416,7 +407,6 @@
       assert.equal(obj.show_whitespace_errors, true);
       assert.equal(obj.syntax_highlighting, true);
       assert.equal(obj.tab_size, 8);
-      assert.equal(obj.theme, 'DEFAULT');
     });
   });
 
@@ -647,19 +637,6 @@
         {message: 'revising...'});
   });
 
-  test('startReview', () => {
-    const sendStub = sinon.stub(element, '_getChangeURLAndSend')
-        .returns(Promise.resolve({}));
-    element.startReview('42', {message: 'Please review.'});
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
-    assert.equal(sendStub.lastCall.args[0].method, 'POST');
-    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-    assert.equal(sendStub.lastCall.args[0].endpoint, '/ready');
-    assert.deepEqual(sendStub.lastCall.args[0].body,
-        {message: 'Please review.'});
-  });
-
   test('deleteComment', () => {
     const sendStub = sinon.stub(element, '_getChangeURLAndSend')
         .returns(Promise.resolve('some response'));
@@ -943,22 +920,18 @@
       });
     });
 
-    test('_getChangeDetail populates _projectLookup', () => {
+    test('_getChangeDetail populates _projectLookup', async () => {
       sinon.stub(element, 'getChangeActionURL')
           .returns(Promise.resolve(''));
       sinon.stub(element._restApiHelper, 'fetchRawJSON')
-          .returns(Promise.resolve({ok: true}));
-
-      const mockResponse = {_number: 1, project: 'test'};
-      sinon.stub(element._restApiHelper, 'readResponsePayload')
           .returns(Promise.resolve({
-            parsed: mockResponse,
-            raw: JSON.stringify(mockResponse),
+            ok: true,
+            status: 200,
+            text: () => Promise.resolve(`)]}'{"_number":1,"project":"test"}`),
           }));
-      return element._getChangeDetail(1, '516714').then(() => {
-        assert.equal(Object.keys(element._projectLookup).length, 1);
-        assert.equal(element._projectLookup[1], 'test');
-      });
+      await element._getChangeDetail(1, '516714');
+      assert.equal(Object.keys(element._projectLookup).length, 1);
+      assert.equal(element._projectLookup[1], 'test');
     });
 
     suite('_getChangeDetail ETag cache', () => {
@@ -969,8 +942,7 @@
       setup(() => {
         requestUrl = '/foo/bar';
         const mockResponse = {foo: 'bar', baz: 42};
-        mockResponseSerial = element.JSON_PREFIX +
-            JSON.stringify(mockResponse);
+        mockResponseSerial = JSON_PREFIX + JSON.stringify(mockResponse);
         sinon.stub(element._restApiHelper, 'urlWithParams')
             .returns(requestUrl);
         sinon.stub(element, 'getChangeActionURL')
@@ -1117,21 +1089,19 @@
   });
 
   suite('reading responses', () => {
-    test('_readResponsePayload', () => {
+    test('_readResponsePayload', async () => {
       const mockObject = {foo: 'bar', baz: 'foo'};
-      const serial = element.JSON_PREFIX + JSON.stringify(mockObject);
+      const serial = JSON_PREFIX + JSON.stringify(mockObject);
       const mockResponse = {text: () => Promise.resolve(serial)};
-      return element._restApiHelper.readResponsePayload(mockResponse)
-          .then(payload => {
-            assert.deepEqual(payload.parsed, mockObject);
-            assert.equal(payload.raw, serial);
-          });
+      const payload = await readResponsePayload(mockResponse);
+      assert.deepEqual(payload.parsed, mockObject);
+      assert.equal(payload.raw, serial);
     });
 
     test('_parsePrefixedJSON', () => {
       const obj = {x: 3, y: {z: 4}, w: 23};
-      const serial = element.JSON_PREFIX + JSON.stringify(obj);
-      const result = element._restApiHelper.parsePrefixedJSON(serial);
+      const serial = JSON_PREFIX + JSON.stringify(obj);
+      const result = parsePrefixedJSON(serial);
       assert.deepEqual(result, obj);
     });
   });
@@ -1279,7 +1249,7 @@
   test('getFileContent suppresses 404s', () => {
     const res = {status: 404};
     const spy = sinon.spy();
-    element.addEventListener('server-error', spy);
+    addListenerForTest(document, 'server-error', spy);
     sinon.stub(appContext.authService, 'fetch').returns(Promise.resolve(res));
     sinon.stub(element, '_changeBaseURL').returns(Promise.resolve(''));
     return element.getFileContent('1', 'tst/path', '1')
@@ -1292,7 +1262,7 @@
         })
         .then(() => {
           assert.isTrue(spy.called);
-          assert.notEqual(spy.lastCall.args[0].detail.res.status, 404);
+          assert.notEqual(spy.lastCall.args[0].detail.response.status, 404);
         });
   });
 
@@ -1332,7 +1302,7 @@
   test('_logCall only reports requests with anonymized URLss', () => {
     sinon.stub(Date, 'now').returns(200);
     const handler = sinon.stub();
-    element.addEventListener('rpc-log', handler);
+    addListenerForTest(document, 'gr-rpc-log', handler);
 
     element._restApiHelper._logCall({url: 'url'}, 100, 200);
     assert.isFalse(handler.called);
@@ -1343,6 +1313,29 @@
     assert.isTrue(handler.calledOnce);
   });
 
+  test('ported comment errors do not trigger error dialog', () => {
+    const change = createChange();
+    const handler = sinon.stub();
+    addListenerForTest(document, 'server-error', handler);
+    sinon.stub(element._restApiHelper, 'fetchJSON').returns(Promise.resolve({
+      ok: false}));
+
+    element.getPortedComments(change._number, CURRENT);
+
+    assert.isFalse(handler.called);
+  });
+
+  test('ported drafts are not requested user is not logged in', () => {
+    const change = createChange();
+    sinon.stub(element, 'getLoggedIn').returns(Promise.resolve(false));
+    const getChangeURLAndFetchStub = sinon.stub(element,
+        '_getChangeURLAndFetch');
+
+    element.getPortedDrafts(change._number, CURRENT);
+
+    assert.isFalse(getChangeURLAndFetchStub.called);
+  });
+
   test('saveChangeStarred', async () => {
     sinon.stub(element, 'getFromProjectLookup')
         .returns(Promise.resolve('test'));
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 6d93604..89abd57 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -15,16 +15,11 @@
  * limitations under the License.
  */
 import {getBaseUrl} from '../../../../utils/url-util';
-import {
-  CancelConditionCallback,
-  ErrorCallback,
-  RestApiService,
-} from '../../../../services/services/gr-rest-api/gr-rest-api';
+import {CancelConditionCallback} from '../../../../services/gr-rest-api/gr-rest-api';
 import {
   AuthRequestInit,
   AuthService,
 } from '../../../../services/gr-auth/gr-auth';
-import {hasOwnProperty} from '../../../../utils/common-util';
 import {
   AccountDetailInfo,
   EmailInfo,
@@ -33,8 +28,11 @@
 } from '../../../../types/common';
 import {HttpMethod} from '../../../../constants/constants';
 import {RpcLogEventDetail} from '../../../../types/events';
+import {fireNetworkError, fireServerError} from '../../../../utils/event-util';
+import {FetchRequest} from '../../../../types/types';
+import {ErrorCallback} from '../../../../api/rest';
 
-const JSON_PREFIX = ")]}'";
+export const JSON_PREFIX = ")]}'";
 
 export interface ResponsePayload {
   // TODO(TS): readResponsePayload can assign null to the parsed property if
@@ -46,6 +44,29 @@
   raw: string;
 }
 
+export function readResponsePayload(
+  response: Response
+): Promise<ResponsePayload> {
+  return response.text().then(text => {
+    let result;
+    try {
+      result = parsePrefixedJSON(text);
+    } catch (_) {
+      result = null;
+    }
+    // TODO(TS): readResponsePayload can assign null to the parsed property if
+    // it can't parse input data. However polygerrit assumes in many places
+    // that the parsed property can't be null. We should update
+    // readResponsePayload method and reject a promise instead of assigning
+    // null to the parsed property
+    return {parsed: result!, raw: text};
+  });
+}
+
+export function parsePrefixedJSON(jsonWithPrefix: string): ParsedJSON {
+  return JSON.parse(jsonWithPrefix.substring(JSON_PREFIX.length)) as ParsedJSON;
+}
+
 /**
  * Wrapper around Map for caching server responses. Site-based so that
  * changes to CANONICAL_PATH will result in a different cache going into
@@ -54,7 +75,7 @@
 export class SiteBasedCache {
   // TODO(TS): Type looks unusual. Fix it.
   // Container of per-canonical-path caches.
-  private readonly _data = new Map<
+  private readonly data = new Map<
     string | undefined,
     unknown | Map<string, ParsedJSON | null>
   >();
@@ -72,10 +93,13 @@
 
   // Returns the cache for the current canonical path.
   _cache(): Map<string, unknown> {
-    if (!this._data.has(window.CANONICAL_PATH)) {
-      this._data.set(window.CANONICAL_PATH, new Map());
+    if (!this.data.has(window.CANONICAL_PATH)) {
+      this.data.set(
+        window.CANONICAL_PATH,
+        new Map<string, ParsedJSON | null>()
+      );
     }
-    return this._data.get(window.CANONICAL_PATH) as Map<
+    return this.data.get(window.CANONICAL_PATH) as Map<
       string,
       ParsedJSON | null
     >;
@@ -110,13 +134,13 @@
   }
 
   invalidatePrefix(prefix: string) {
-    const newMap = new Map();
+    const newMap = new Map<string, unknown>();
     for (const [key, value] of this._cache().entries()) {
       if (!key.startsWith(prefix)) {
         newMap.set(key, value);
       }
     }
-    this._data.set(window.CANONICAL_PATH, newMap);
+    this.data.set(window.CANONICAL_PATH, newMap);
   }
 }
 
@@ -125,25 +149,25 @@
 };
 
 export class FetchPromisesCache {
-  private _data: FetchPromisesCacheData;
+  private data: FetchPromisesCacheData;
 
   constructor() {
-    this._data = {};
+    this.data = {};
   }
 
   public testOnlyGetData() {
-    return this._data;
+    return this.data;
   }
 
   /**
    * @return true only if a value for a key sets and it is not undefined
    */
   has(key: string): boolean {
-    return !!this._data[key];
+    return !!this.data[key];
   }
 
   get(key: string) {
-    return this._data[key];
+    return this.data[key];
   }
 
   /**
@@ -151,17 +175,17 @@
    *     mark key as deleted.
    */
   set(key: string, value: Promise<ParsedJSON | undefined> | undefined) {
-    this._data[key] = value;
+    this.data[key] = value;
   }
 
   invalidatePrefix(prefix: string) {
     const newData: FetchPromisesCacheData = {};
-    Object.entries(this._data).forEach(([key, value]) => {
+    Object.entries(this.data).forEach(([key, value]) => {
       if (!key.startsWith(prefix)) {
         newData[key] = value;
       }
     });
-    this._data = newData;
+    this.data = newData;
   }
 }
 export type FetchParams = {
@@ -189,12 +213,6 @@
 
 export type SendRequest = SendRawRequest | SendJSONRequest;
 
-export interface FetchRequest {
-  url: string;
-  fetchOptions?: AuthRequestInit;
-  anonymizedUrl?: string;
-}
-
 export interface FetchJSONRequest extends FetchRequest {
   reportUrlAsIs?: boolean;
   params?: FetchParams;
@@ -218,8 +236,7 @@
   constructor(
     private readonly _cache: SiteBasedCache,
     private readonly _auth: AuthService,
-    private readonly _fetchPromisesCache: FetchPromisesCache,
-    private readonly _restApiInterface: RestApiService
+    private readonly _fetchPromisesCache: FetchPromisesCache
   ) {}
 
   /**
@@ -277,8 +294,8 @@
         elapsed,
         anonymizedUrl: req.anonymizedUrl,
       };
-      this.dispatchEvent(
-        new CustomEvent('rpc-log', {
+      document.dispatchEvent(
+        new CustomEvent('gr-rpc-log', {
           detail,
           composed: true,
           bubbles: true,
@@ -317,13 +334,7 @@
         if (req.errFn) {
           req.errFn.call(undefined, null, err);
         } else {
-          this.dispatchEvent(
-            new CustomEvent('network-error', {
-              detail: {error: err},
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fireNetworkError(err);
         }
         throw err;
       });
@@ -352,13 +363,7 @@
           req.errFn.call(null, response);
           return;
         }
-        this.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail: {request: req, response},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireServerError(response, req);
         return;
       }
       return this.getResponseObject(response);
@@ -371,11 +376,7 @@
     }
 
     const params: Array<string | number | boolean> = [];
-    for (const p in fetchParams) {
-      if (!hasOwnProperty(fetchParams, p)) {
-        continue;
-      }
-      const paramValue = fetchParams[p];
+    for (const [p, paramValue] of Object.entries(fetchParams)) {
       // TODO(TS): Replace == null with === and check for null and undefined
       // eslint-disable-next-line eqeqeq
       if (paramValue == null) {
@@ -405,30 +406,7 @@
   }
 
   getResponseObject(response: Response): Promise<ParsedJSON> {
-    return this.readResponsePayload(response).then(payload => payload.parsed);
-  }
-
-  readResponsePayload(response: Response): Promise<ResponsePayload> {
-    return response.text().then(text => {
-      let result;
-      try {
-        result = this.parsePrefixedJSON(text);
-      } catch (_) {
-        result = null;
-      }
-      // TODO(TS): readResponsePayload can assign null to the parsed property if
-      // it can't parse input data. However polygerrit assumes in many places
-      // that the parsed property can't be null. We should update
-      // readResponsePayload method and reject a promise instead of assigning
-      // null to the parsed property
-      return {parsed: result!, raw: text};
-    });
-  }
-
-  parsePrefixedJSON(jsonWithPrefix: string): ParsedJSON {
-    return JSON.parse(
-      jsonWithPrefix.substring(JSON_PREFIX.length)
-    ) as ParsedJSON;
+    return readResponsePayload(response).then(payload => payload.parsed);
   }
 
   addAcceptJsonHeader(req: FetchJSONRequest) {
@@ -440,10 +418,6 @@
     return req;
   }
 
-  dispatchEvent(type: Event, detail?: unknown): boolean {
-    return this._restApiInterface.dispatchEvent(type, detail);
-  }
-
   fetchCacheURL(req: FetchJSONRequest): Promise<ParsedJSON | undefined> {
     if (this._fetchPromisesCache.has(req.url)) {
       return this._fetchPromisesCache.get(req.url)!;
@@ -483,7 +457,7 @@
    * Send an XHR.
    *
    * @return Promise resolves to Response/ParsedJSON only if the request is successful
-   *     (i.e. no exception and response.ok is trsue). If response fails then
+   *     (i.e. no exception and response.ok is true). If response fails then
    *     promise resolves either to void if errFn is set or rejects if errFn
    *     is not set   */
   send(req: SendRequest): Promise<Response | ParsedJSON | undefined> {
@@ -501,11 +475,8 @@
       if (!options.headers) {
         options.headers = new Headers();
       }
-      for (const header in req.headers) {
-        if (!hasOwnProperty(req.headers, header)) {
-          continue;
-        }
-        options.headers.set(header, req.headers[header]);
+      for (const [name, value] of Object.entries(req.headers)) {
+        options.headers.set(name, value);
       }
     }
     const url = req.url.startsWith('http') ? req.url : getBaseUrl() + req.url;
@@ -515,35 +486,23 @@
       anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
     };
     const xhr = this.fetch(fetchReq)
-      .then(response => {
-        if (!response.ok) {
-          if (req.errFn) {
-            req.errFn.call(undefined, response);
-            return;
-          }
-          this.dispatchEvent(
-            new CustomEvent('server-error', {
-              detail: {request: fetchReq, response},
-              composed: true,
-              bubbles: true,
-            })
-          );
-        }
-        return response;
-      })
       .catch(err => {
-        this.dispatchEvent(
-          new CustomEvent('network-error', {
-            detail: {error: err},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireNetworkError(err);
         if (req.errFn) {
           return req.errFn.call(undefined, null, err);
         } else {
           throw err;
         }
+      })
+      .then(response => {
+        if (response && !response.ok) {
+          if (req.errFn) {
+            req.errFn.call(undefined, response);
+            return;
+          }
+          fireServerError(response, fetchReq);
+        }
+        return response;
       });
 
     if (req.parseResponse) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-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 4eef8a2f..3a5a587 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
@@ -16,8 +16,7 @@
  */
 
 import '../../../../test/common-test-setup-karma.js';
-import {SiteBasedCache} from './gr-rest-api-helper.js';
-import {FetchPromisesCache, GrRestApiHelper} from './gr-rest-api-helper.js';
+import {SiteBasedCache, FetchPromisesCache, GrRestApiHelper} from './gr-rest-api-helper.js';
 import {appContext} from '../../../../services/app-context.js';
 
 suite('gr-rest-api-helper tests', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
index 48a23c6..95e06c0 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
@@ -22,14 +22,14 @@
   ChangeInfo,
   ChangeMessageInfo,
   ChangeViewChangeInfo,
-  CommitInfo,
-  PatchSetNum,
   ReviewerUpdateInfo,
-  RevisionInfo,
   Timestamp,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {accountKey} from '../../../utils/account-util';
+import {
+  FormattedReviewerUpdateInfo,
+  ParsedChangeInfo,
+} from '../../../types/types';
 
 const MESSAGE_REVIEWERS_THRESHOLD_MILLIS = 500;
 const REVIEWER_UPDATE_THRESHOLD_MILLIS = 6000;
@@ -61,14 +61,6 @@
   updates: UpdateItem[]; // Always has at least 1 items
 }
 
-export interface FormattedReviewerUpdateInfo {
-  author: AccountInfo;
-  date: Timestamp;
-  type: 'REVIEWER_UPDATE';
-  tag: MessageTag.TAG_REVIEWER_UPDATE;
-  updates: {message: string; reviewers: AccountInfo[]}[];
-}
-
 function isParserBatchWithNonEmptyUpdates(
   x: ParserBatch
 ): x is ParserBatchWithNonEmptyUpdates {
@@ -81,19 +73,6 @@
   prev_state?: ReviewerState;
 }
 
-export interface EditRevisionInfo extends Partial<RevisionInfo> {
-  // EditRevisionInfo has less required properties then RevisionInfo
-  _number: PatchSetNum;
-  basePatchNum: PatchSetNum;
-  commit: CommitInfo;
-}
-
-export interface ParsedChangeInfo
-  extends Omit<ChangeViewChangeInfo, 'reviewer_updates' | 'revisions'> {
-  revisions: {[revisionId: string]: RevisionInfo | EditRevisionInfo};
-  reviewer_updates?: ReviewerUpdateInfo[] | FormattedReviewerUpdateInfo[];
-}
-
 type ReviewersGroupByMessage = {[message: string]: AccountInfo[]};
 
 export class GrReviewerUpdatesParser {
@@ -102,9 +81,9 @@
   // type. This class should be refactored to avoid reassignment.
   private readonly result: ChangeInfoParserInput;
 
-  private _batch: ParserBatch | null = null;
+  private batch: ParserBatch | null = null;
 
-  private _updateItems: {[accountId: string]: UpdateItem} | null = null;
+  private updateItems: {[accountId: string]: UpdateItem} | null = null;
 
   private readonly _lastState: {[accountId: string]: ReviewerState} = {};
 
@@ -126,7 +105,7 @@
    * Is a part of _groupUpdates(). Creates a new batch of updates.
    */
   private _startBatch(update: ReviewerUpdateInfo): ParserBatch {
-    this._updateItems = {};
+    this.updateItems = {};
     return {
       author: update.updated_by,
       date: update.updated,
@@ -142,12 +121,10 @@
    */
   private _completeBatch(batch: ParserBatch) {
     const items = [];
-    for (const accountId in this._updateItems) {
-      if (!hasOwnProperty(this._updateItems, accountId)) continue;
-      const updateItem = this._updateItems[accountId];
-      if (this._lastState[accountId] !== updateItem.state) {
-        this._lastState[accountId] = updateItem.state;
-        items.push(updateItem);
+    for (const [accountId, item] of Object.entries(this.updateItems ?? {})) {
+      if (this._lastState[accountId] !== item.state) {
+        this._lastState[accountId] = item.state;
+        items.push(item);
       }
     }
     if (items.length) {
@@ -165,27 +142,27 @@
   _groupUpdates(): ParserBatchWithNonEmptyUpdates[] {
     const updates = this.result.reviewer_updates;
     const newUpdates = updates.reduce((newUpdates, update) => {
-      if (!this._batch) {
-        this._batch = this._startBatch(update);
+      if (!this.batch) {
+        this.batch = this._startBatch(update);
       }
       const updateDate = parseDate(update.updated).getTime();
-      const batchUpdateDate = parseDate(this._batch.date).getTime();
+      const batchUpdateDate = parseDate(this.batch.date).getTime();
       const reviewerId = accountKey(update.reviewer);
       if (
         updateDate - batchUpdateDate > REVIEWER_UPDATE_THRESHOLD_MILLIS ||
-        update.updated_by._account_id !== this._batch.author._account_id
+        update.updated_by._account_id !== this.batch.author._account_id
       ) {
         // Next sequential update should form new group.
-        this._completeBatch(this._batch);
-        if (isParserBatchWithNonEmptyUpdates(this._batch)) {
-          newUpdates.push(this._batch);
+        this._completeBatch(this.batch);
+        if (isParserBatchWithNonEmptyUpdates(this.batch)) {
+          newUpdates.push(this.batch);
         }
-        this._batch = this._startBatch(update);
+        this.batch = this._startBatch(update);
       }
-      // _startBatch assigns _updateItems. When _groupUpdates is calling,
-      // _batch and _updateItems are not set => _startBatch is called. The
-      // _startBatch method assigns _updateItems
-      const updateItems = this._updateItems!;
+      // _startBatch assigns updateItems. When _groupUpdates is calling,
+      // batch and updateItems are not set => _startBatch is called. The
+      // _startBatch method assigns updateItems
+      const updateItems = this.updateItems!;
       updateItems[reviewerId] = {
         reviewer: update.reviewer,
         state: update.state,
@@ -197,8 +174,8 @@
     }, [] as ParserBatchWithNonEmptyUpdates[]);
     // reviewer_updates always has at least 1 item
     // (otherwise parse is not created) => updates.reduce calls callback
-    // at least once and callback assigns this._batch
-    const batch = this._batch!;
+    // at least once and callback assigns this.batch
+    const batch = this.batch!;
     this._completeBatch(batch);
     if (isParserBatchWithNonEmptyUpdates(batch)) {
       newUpdates.push(batch);
@@ -253,15 +230,10 @@
     const reviewerUpdates = (this.result
       .reviewer_updates as unknown) as ParserBatchWithNonEmptyUpdates[];
     for (const update of reviewerUpdates) {
-      const grouppedReviewers = this._groupUpdatesByMessage(update.updates);
+      const groupedReviewers = this._groupUpdatesByMessage(update.updates);
       const newUpdates: {message: string; reviewers: AccountInfo[]}[] = [];
-      for (const message in grouppedReviewers) {
-        if (hasOwnProperty(grouppedReviewers, message)) {
-          newUpdates.push({
-            message,
-            reviewers: grouppedReviewers[message],
-          });
-        }
+      for (const [message, reviewers] of Object.entries(groupedReviewers)) {
+        newUpdates.push({message, reviewers});
       }
       ((update as unknown) as FormattedReviewerUpdateInfo).updates = newUpdates;
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
index a2c1253..861381f 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {customElement, property, observe} from '@polymer/decorators';
@@ -30,9 +28,7 @@
  * GrSelect `gr-select` component.
  */
 @customElement('gr-select')
-export class GrSelect extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrSelect extends PolymerElement {
   static get template() {
     return html` <slot></slot> `;
   }
@@ -57,7 +53,7 @@
       // 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
-      this.async(() => {
+      setTimeout(() => {
         // TODO(TS): maybe should check for undefined before assigning
         // or fallback to ''
         this.nativeSelect.value = this.bindValue!;
@@ -73,9 +69,8 @@
     this.nativeSelect.focus();
   }
 
-  /** @override */
-  created() {
-    super.created();
+  constructor() {
+    super();
     this.addEventListener('change', () => this._valueChanged());
     this.addEventListener('dom-change', () => this._updateValue());
   }
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 27f4069..a23cb1a 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
@@ -16,8 +16,6 @@
  */
 import '../../../styles/shared-styles';
 import '../gr-copy-clipboard/gr-copy-clipboard';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-shell-command_html';
 import {customElement, property} from '@polymer/decorators';
@@ -29,9 +27,7 @@
 }
 
 @customElement('gr-shell-command')
-class GrShellCommand extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrShellCommand extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
deleted file mode 100644
index 1369a17..0000000
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
+++ /dev/null
@@ -1,183 +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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
-import {CommentRange, PatchSetNum} from '../../../types/common';
-
-export interface StorageLocation {
-  changeNum: number;
-  patchNum: PatchSetNum | '@change';
-  path?: string;
-  line?: number;
-  range?: CommentRange;
-}
-
-export interface StorageObject {
-  message?: string;
-  updated: number;
-}
-
-const DURATION_DAY = 24 * 60 * 60 * 1000;
-
-// Clean up old entries no more frequently than one day.
-const CLEANUP_THROTTLE_INTERVAL = DURATION_DAY;
-
-const CLEANUP_PREFIXES_MAX_AGE_MAP = new Map<string, number>();
-CLEANUP_PREFIXES_MAX_AGE_MAP.set('respectfultip', 14 * DURATION_DAY);
-CLEANUP_PREFIXES_MAX_AGE_MAP.set('draft', DURATION_DAY);
-CLEANUP_PREFIXES_MAX_AGE_MAP.set('editablecontent', DURATION_DAY);
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-storage': GrStorage;
-  }
-}
-
-export interface GrStorage {
-  $: {};
-}
-
-@customElement('gr-storage')
-export class GrStorage extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
-  @property({type: Number})
-  _lastCleanup = 0;
-
-  @property({type: Object})
-  _storage = window.localStorage;
-
-  @property({type: Boolean})
-  _exceededQuota = false;
-
-  getDraftComment(location: StorageLocation): StorageObject | null {
-    this._cleanupItems();
-    return this._getObject(this._getDraftKey(location));
-  }
-
-  setDraftComment(location: StorageLocation, message: string) {
-    const key = this._getDraftKey(location);
-    this._setObject(key, {message, updated: Date.now()});
-  }
-
-  eraseDraftComment(location: StorageLocation) {
-    const key = this._getDraftKey(location);
-    this._storage.removeItem(key);
-  }
-
-  getEditableContentItem(key: string): StorageObject | null {
-    this._cleanupItems();
-    return this._getObject(this._getEditableContentKey(key));
-  }
-
-  setEditableContentItem(key: string, message: string) {
-    this._setObject(this._getEditableContentKey(key), {
-      message,
-      updated: Date.now(),
-    });
-  }
-
-  getRespectfulTipVisibility(): StorageObject | null {
-    this._cleanupItems();
-    return this._getObject('respectfultip:visibility');
-  }
-
-  setRespectfulTipVisibility(delayDays = 0) {
-    this._cleanupItems();
-    this._setObject('respectfultip:visibility', {
-      updated: Date.now() + delayDays * DURATION_DAY,
-    });
-  }
-
-  eraseEditableContentItem(key: string) {
-    this._storage.removeItem(this._getEditableContentKey(key));
-  }
-
-  _getDraftKey(location: StorageLocation): string {
-    const range = location.range
-      ? `${location.range.start_line}-${location.range.start_character}` +
-        `-${location.range.end_character}-${location.range.end_line}`
-      : null;
-    let key = [
-      'draft',
-      location.changeNum,
-      location.patchNum,
-      location.path,
-      location.line || '',
-    ].join(':');
-    if (range) {
-      key = key + ':' + range;
-    }
-    return key;
-  }
-
-  _getEditableContentKey(key: string): string {
-    return `editablecontent:${key}`;
-  }
-
-  _cleanupItems() {
-    // Throttle cleanup to the throttle interval.
-    if (
-      this._lastCleanup &&
-      Date.now() - this._lastCleanup < CLEANUP_THROTTLE_INTERVAL
-    ) {
-      return;
-    }
-    this._lastCleanup = Date.now();
-
-    Object.keys(this._storage).forEach(key => {
-      const entries = CLEANUP_PREFIXES_MAX_AGE_MAP.entries();
-      for (const [prefix, expiration] of entries) {
-        if (key.startsWith(prefix)) {
-          const item = this._getObject(key);
-          if (!item || Date.now() - item.updated > expiration) {
-            this._storage.removeItem(key);
-          }
-        }
-      }
-    });
-  }
-
-  _getObject(key: string): StorageObject | null {
-    const serial = this._storage.getItem(key);
-    if (!serial) {
-      return null;
-    }
-    return JSON.parse(serial) as StorageObject;
-  }
-
-  _setObject(key: string, obj: StorageObject) {
-    if (this._exceededQuota) {
-      return;
-    }
-    try {
-      this._storage.setItem(key, JSON.stringify(obj));
-    } catch (exc) {
-      // Catch for QuotaExceededError and disable writes on local storage the
-      // first time that it occurs.
-      if (exc.code === 22) {
-        this._exceededQuota = true;
-        console.warn('Local storage quota exceeded: disabling');
-        return;
-      } else {
-        throw exc;
-      }
-    }
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index b3c0a85..bf10121 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -21,8 +21,6 @@
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-textarea_html';
 import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
@@ -38,32 +36,23 @@
   {value: '😊', match: 'smile :)'},
   {value: '👍', match: 'thumbs up'},
   {value: '😄', match: 'laugh :D'},
-  {value: '🎉', match: 'party'},
-  {value: '😞', match: 'sad :('},
+  {value: '❤️', match: 'heart <3'},
   {value: '😂', match: "tears :')"},
-  {value: '🙏', match: 'pray'},
+  {value: '🎉', match: 'party'},
+  {value: '😎', match: 'cool |;)'},
+  {value: '😞', match: 'sad :('},
   {value: '😐', match: 'neutral :|'},
   {value: '😮', match: 'shock :O'},
-  {value: '👎', match: 'thumbs down'},
-  {value: '😎', match: 'cool |;)'},
+  {value: '🙏', match: 'pray'},
   {value: '😕', match: 'confused'},
   {value: '👌', match: 'ok'},
   {value: '🔥', match: 'fire'},
-  {value: '👊', match: 'fistbump'},
   {value: '💯', match: '100'},
-  {value: '💔', match: 'broken heart'},
-  {value: '🍺', match: 'beer'},
   {value: '✔', match: 'check'},
   {value: '😋', match: 'tongue'},
   {value: '😭', match: "crying :'("},
-  {value: '🐨', match: 'koala'},
   {value: '🤓', match: 'glasses'},
-  {value: '😆', match: 'grin'},
-  {value: '💩', match: 'poop'},
   {value: '😢', match: 'tear'},
-  {value: '😒', match: 'unamused'},
-  {value: '😉', match: 'wink ;)'},
-  {value: '🍷', match: 'wine'},
   {value: '😜', match: 'winking tongue ;)'},
 ];
 
@@ -86,13 +75,9 @@
     hiddenText: HTMLDivElement;
   };
 }
-/**
- * @extends PolymerElement
- */
+
 @customElement('gr-textarea')
-export class GrTextarea extends KeyboardShortcutMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrTextarea extends KeyboardShortcutMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -121,7 +106,7 @@
   @property({type: Boolean})
   hideBorder = false;
 
-  /** Text input should be rendered in monspace font.  */
+  /** Text input should be rendered in monospace font.  */
   @property({type: Boolean})
   monospace = false;
 
@@ -195,7 +180,7 @@
     // Put the cursor at the end always.
     textarea.selectionStart = textarea.value.length;
     textarea.selectionEnd = textarea.selectionStart;
-    this.async(() => {
+    setTimeout(() => {
       textarea.focus();
     });
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts
index 1f777aa..d55481b 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts
@@ -31,7 +31,8 @@
     :host(.code) {
       font-family: var(--monospace-font-family);
       font-size: var(--font-size-code);
-      line-height: var(--line-height-code);
+      /* usually 16px = 12px + 4px */
+      line-height: calc(var(--font-size-code) + var(--spacing-s));
       font-weight: var(--font-weight-normal);
     }
     #emojiSuggestions {
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
index cfd9e81..feed8a6 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,8 +15,6 @@
  * limitations under the License.
  */
 import '../gr-icons/gr-icons';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-tooltip-content_html';
 import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
@@ -32,9 +30,7 @@
  * Transclude anything inside and wrap them to support tooltip functionality.
  */
 @customElement('gr-tooltip-content')
-export class GrTooltipContent extends TooltipMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrTooltipContent extends TooltipMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
index c1a8eb2..cab05b4 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
@@ -15,8 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-tooltip_html';
 import {customElement, property, observe} from '@polymer/decorators';
@@ -32,9 +30,7 @@
 }
 
 @customElement('gr-tooltip')
-export class GrTooltip extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrTooltip extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
index fadbfa7..b876f2e 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
@@ -15,9 +15,8 @@
  * limitations under the License.
  */
 
-import {patchNumEquals} from '../../../utils/patch-set-util';
 import {ChangeInfo, PatchSetNum} from '../../../types/common';
-import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
+import {ParsedChangeInfo} from '../../../types/types';
 
 type RevNumberToParentCountMap = {[revNumber: number]: number};
 
@@ -71,8 +70,8 @@
 
   getParentId(patchNum: PatchSetNum, parentIndex: number) {
     if (!this.change.revisions) return;
-    const rev = Object.values(this.change.revisions).find(rev =>
-      patchNumEquals(rev._number, patchNum)
+    const rev = Object.values(this.change.revisions).find(
+      rev => rev._number === patchNum
     );
     if (!rev || !rev.commit) return;
     return rev.commit.parents[parentIndex].commit;
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 405d22e..6999ef8 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -25,16 +25,10 @@
 import '../elements/diff/gr-diff/gr-diff';
 import '../elements/diff/gr-diff-cursor/gr-diff-cursor';
 import {initDiffAppContext} from './gr-diff-app-context-init';
-import {
-  GrDiffLine,
-  GrDiffLineType,
-} from '../elements/diff/gr-diff/gr-diff-line';
 import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation';
 
 // Setup appContext for diff.
 // TODO (dmfilippov): find a better solution
 initDiffAppContext();
 // Setup global variables for existing usages of this component
-window.GrDiffLine = GrDiffLine;
-window.GrDiffLineType = GrDiffLineType;
 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
index abbcbc8..28b7a50 100644
--- a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
+++ b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
@@ -48,14 +48,6 @@
         'Size',
       ];
 
-      /**
-       * Returns the complement to the given column array
-       *
-       */
-      getComplementColumns(columns: string[]) {
-        return this.columnNames.filter(column => !columns.includes(column));
-      }
-
       isColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]) {
         if (!columnsToDisplay || !columnToCheck) {
           return false;
@@ -103,7 +95,7 @@
        * @return If the column was renamed, returns a new array
        * with the corrected name. Otherwise, it returns the original param.
        */
-      getVisibleColumns(columns: string[]) {
+      renameProjectToRepoColumn(columns: string[]) {
         const projectIndex = columns.indexOf('Project');
         if (projectIndex === -1) {
           return columns;
@@ -120,7 +112,6 @@
 
 export interface ChangeTableMixinInterface {
   readonly columnNames: string[];
-  getComplementColumns(columns: string[]): string[];
   isColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]): boolean;
   isColumnEnabled(
     column: string,
@@ -132,5 +123,5 @@
     config: ServerInfo,
     experiments: string[]
   ): string[];
-  getVisibleColumns(columns: string[]): string[];
+  renameProjectToRepoColumn(columns: string[]): string[];
 }
diff --git a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js
index daf10ce..0d6b4ad 100644
--- a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js
+++ b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js
@@ -37,35 +37,6 @@
     element = basicFixture.instantiate();
   });
 
-  test('getComplementColumns', () => {
-    let columns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Updated',
-      'Size',
-    ];
-    assert.deepEqual(element.getComplementColumns(columns), []);
-
-    columns = [
-      'Subject',
-      'Status',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Size',
-    ];
-    assert.deepEqual(element.getComplementColumns(columns),
-        ['Owner', 'Updated']);
-  });
-
   test('isColumnHidden', () => {
     const columnToCheck = 'Repo';
     let columnsToDisplay = [
@@ -92,15 +63,16 @@
     assert.isTrue(element.isColumnHidden(columnToCheck, columnsToDisplay));
   });
 
-  test('getVisibleColumns maps Project to Repo', () => {
+  test('renameProjectToRepoColumn maps Project to Repo', () => {
     const columns = [
       'Subject',
       'Status',
       'Owner',
     ];
-    assert.deepEqual(element.getVisibleColumns(columns), columns.slice(0));
+    assert.deepEqual(element.renameProjectToRepoColumn(columns),
+        columns.slice(0));
     assert.deepEqual(
-        element.getVisibleColumns(columns.concat(['Project'])),
+        element.renameProjectToRepoColumn(columns.concat(['Project'])),
         columns.slice(0).concat(['Repo']));
   });
 });
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
index 08b18a3..e60c614 100644
--- a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
+++ b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
@@ -69,16 +69,16 @@
       // Handler for mouseenter event
       private mouseenterHandler?: (e: MouseEvent) => void;
 
-      // Hanlder for scrolling on window
+      // Handler for scrolling on window
       private readonly windowScrollHandler: () => void;
 
-      // Hanlder for showing the tooltip, will be attached to certain events
+      // Handler for showing the tooltip, will be attached to certain events
       private readonly showHandler: () => void;
 
-      // Hanlder for hiding the tooltip, will be attached to certain events
+      // Handler for hiding the tooltip, will be attached to certain events
       private readonly hideHandler: () => void;
 
-      // tslint:disable-next-line:no-any Required for constructor signature.
+      // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
       constructor(..._: any[]) {
         super();
         this.windowScrollHandler = () => this._handleWindowScroll();
@@ -88,7 +88,6 @@
 
       /** @override */
       disconnectedCallback() {
-        super.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();
@@ -96,6 +95,7 @@
           this.removeEventListener('mouseenter', this.mouseenterHandler);
         }
         window.removeEventListener('scroll', this.windowScrollHandler);
+        super.disconnectedCallback();
       }
 
       @observe('hasTooltip')
diff --git a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
index 48a4848..57e034f 100644
--- a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
+++ b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
@@ -29,7 +29,7 @@
 // is used. To ensure that this import can't be avoided, the second parameter
 // is added. Usage example:
 // class Element extends IronFitMixin(PolymerElement, IronFitBehavior as IronFitBehavior)
-// The code 'IronFitBehavior as IronFitBehavior' required, becuase IronFitBehavior
+// The code 'IronFitBehavior as IronFitBehavior' required, because IronFitBehavior
 // defined as an object, not as IronFitBehavior instance.
 
 export const IronFitMixin = <T extends Constructor<PolymerElement>>(
@@ -38,4 +38,5 @@
 ): T & Constructor<IronFitBehavior> =>
   // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
   // which will fail the type check due to missing IronFitBehavior interface
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   mixinBehaviors([IronFitBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
index 4884ec2..8429e38 100644
--- a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
+++ b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
@@ -38,4 +38,5 @@
   // TODO(TS): mixinBehaviors in some lib is returning: `new () => T`
   // instead which will fail the type check due to missing
   // IronOverlayBehavior interface
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   mixinBehaviors([IronOverlayBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index e383811..7b740fb 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -129,7 +129,7 @@
 export enum ShortcutSection {
   ACTIONS = 'Actions',
   DIFFS = 'Diffs',
-  EVERYWHERE = 'Everywhere',
+  EVERYWHERE = 'Global Shortcuts',
   FILE_LIST = 'File list',
   NAVIGATION = 'Navigation',
   REPLY_DIALOG = 'Reply dialog',
@@ -175,7 +175,7 @@
   VISIBLE_LINE = 'VISIBLE_LINE',
   NEXT_CHUNK = 'NEXT_CHUNK',
   PREV_CHUNK = 'PREV_CHUNK',
-  EXPAND_ALL_DIFF_CONTEXT = 'EXPAND_ALL_DIFF_CONTEXT',
+  TOGGLE_ALL_DIFF_CONTEXT = 'TOGGLE_ALL_DIFF_CONTEXT',
   NEXT_COMMENT_THREAD = 'NEXT_COMMENT_THREAD',
   PREV_COMMENT_THREAD = 'PREV_COMMENT_THREAD',
   EXPAND_ALL_COMMENT_THREADS = 'EXPAND_ALL_COMMENT_THREADS',
@@ -403,9 +403,9 @@
   'Go to previous diff chunk'
 );
 _describe(
-  Shortcut.EXPAND_ALL_DIFF_CONTEXT,
+  Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
   ShortcutSection.DIFFS,
-  'Expand all diff context'
+  'Toggle all diff context'
 );
 _describe(
   Shortcut.NEXT_COMMENT_THREAD,
@@ -544,7 +544,7 @@
 }
 
 /**
- * Shortcut manager, holds all hosts, bindings and listners.
+ * Shortcut manager, holds all hosts, bindings and listeners.
  */
 export class ShortcutManager {
   private readonly activeHosts = new Map<PolymerElement, Map<string, string>>();
@@ -773,6 +773,7 @@
 
 interface IronA11yKeysMixinConstructor {
   // Note: this is needed to have same interface as other mixins
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   new (...args: any[]): IronA11yKeysBehavior;
 }
 /**
@@ -795,10 +796,10 @@
       _shortcut_v_key_last_pressed: number | null = null;
 
       @property({type: Object})
-      _shortcut_go_table: Map<string, string> = new Map();
+      _shortcut_go_table: Map<string, string> = new Map<string, string>();
 
       @property({type: Object})
-      _shortcut_v_table: Map<string, string> = new Map();
+      _shortcut_v_table: Map<string, string> = new Map<string, string>();
 
       Shortcut = Shortcut;
 
@@ -836,8 +837,9 @@
           // 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.
-          (e.keyCode === 13 && tagName === 'A')
+          // Suppress shortcuts if the key is 'enter'
+          // and target is an anchor or button.
+          (e.keyCode === 13 && (tagName === 'A' || tagName === 'BUTTON'))
         ) {
           return true;
         }
@@ -960,10 +962,10 @@
 
       /** @override */
       disconnectedCallback() {
-        super.disconnectedCallback();
         if (shortcutManager.detachHost(this)) {
           this.removeOwnKeyBindings();
         }
+        super.disconnectedCallback();
       }
 
       keyboardShortcuts() {
@@ -1008,6 +1010,7 @@
         const handler = this._shortcut_v_table.get(e.detail.key);
         if (handler) {
           // TODO(TS): should fix this
+          // eslint-disable-next-line @typescript-eslint/no-explicit-any
           (this as any)[handler](e);
         }
       }
@@ -1044,6 +1047,7 @@
         const handler = this._shortcut_go_table.get(e.detail.key);
         if (handler) {
           // TODO(TS): should fix this
+          // eslint-disable-next-line @typescript-eslint/no-explicit-any
           (this as any)[handler](e);
         }
       }
@@ -1063,13 +1067,13 @@
 // This is a workaround
 export const KeyboardShortcutMixin = <T extends Constructor<PolymerElement>>(
   superClass: T
-): T & Constructor<KeyboardShortcutMixinInterface> => {
-  return InternalKeyboardShortcutMixin(
+): 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
   );
-};
 
 /** The interface corresponding to KeyboardShortcutMixin */
 export interface KeyboardShortcutMixinInterface {
diff --git a/polygerrit-ui/app/node_modules_licenses/BUILD b/polygerrit-ui/app/node_modules_licenses/BUILD
index 77400c6..a2fddb1 100644
--- a/polygerrit-ui/app/node_modules_licenses/BUILD
+++ b/polygerrit-ui/app/node_modules_licenses/BUILD
@@ -17,7 +17,8 @@
     tsconfig = "tsconfig.json",
     deps = [
         "//tools/node_tools/node_modules_licenses:licenses-map",
-        "@tools_npm//:node_modules",
+        "@tools_npm//@bazel/typescript",
+        "@tools_npm//@types/node",
     ],
 )
 
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 81c2f5e..8dcb80e 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -35,6 +35,11 @@
     name: "BSD-3-Clause",
     allowed: true
   };
+
+  public static BsdZeroClause: LicenseType = {
+    name: "BSD-Zero-Clause",
+    allowed: true
+  };
 }
 
 /** List of licenses texts. Add the licenses here if there is no text file with license
@@ -52,6 +57,12 @@
     sharedLicenseFile: "polymer-2015.txt",
   };
 
+  public static Polymer2016: LicenseInfo = {
+    name: "Polymer-2016",
+    type: LicenseTypes.Bsd3,
+    sharedLicenseFile: "polymer-2016.txt",
+  };
+
   public static Polymer2017: LicenseInfo = {
     name: "Polymer-2017",
     type: LicenseTypes.Bsd3,
@@ -161,6 +172,10 @@
     license: SharedLicenses.Polymer2015
   },
   {
+    name: "@polymer/iron-image",
+    license: SharedLicenses.Polymer2016
+  },
+  {
     name: "@polymer/iron-input",
     license: SharedLicenses.Polymer2015
   },
@@ -201,6 +216,14 @@
     license: SharedLicenses.Polymer2015
   },
   {
+    name: "@polymer/paper-card",
+    license: SharedLicenses.Polymer2015
+  },
+  {
+    name: "@polymer/paper-checkbox",
+    license: SharedLicenses.Polymer2016
+  },
+  {
     name: "@polymer/paper-dialog",
     license: SharedLicenses.Polymer2015
   },
@@ -213,6 +236,10 @@
     license: SharedLicenses.Polymer2015
   },
   {
+    name: "@polymer/paper-dropdown-menu",
+    license: SharedLicenses.Polymer2015
+  },
+  {
     name: "@polymer/paper-icon-button",
     license: SharedLicenses.Polymer2015
   },
@@ -229,6 +256,10 @@
     license: SharedLicenses.Polymer2015
   },
   {
+    name: "@polymer/paper-menu-button",
+    license: SharedLicenses.Polymer2015
+  },
+  {
     name: "@polymer/paper-ripple",
     license: SharedLicenses.Polymer2014
   },
@@ -245,10 +276,22 @@
     license: SharedLicenses.Polymer2015
   },
   {
+    name: "@polymer/paper-tooltip",
+    license: SharedLicenses.Polymer2015
+  },
+  {
     name: "@polymer/polymer",
     license: SharedLicenses.Polymer2017
   },
   {
+    name: "@types/resize-observer-browser",
+    license: {
+      name: 'DefinitelyTyped',
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE"
+    }
+  },
+  {
     name: "@webcomponents/shadycss",
     license: SharedLicenses.Polymer2017
   },
@@ -295,7 +338,45 @@
   {
     name: "polymer-bridges",
     license: SharedLicenses.Polymer2018
-  }
+  },
+  {
+    name: "rxjs",
+    license: {
+      name: "rxjs",
+      type: LicenseTypes.Apache2_0,
+      packageLicenseFile: "LICENSE.txt"
+    },
+    // The following directories are not real packages, but contains package.json
+    nonPackages: [
+      "ajax", "fetch", "internal-compatibility", "operators", "testing",
+      "webSocket", "src/ajax", "src/fetch", "src/internal-compatibility",
+      "src/operators", "src/testing", "src/webSocket"],
+  },
+  {
+    name: "lit-element",
+    license: {
+      name: "lit-element",
+      type: LicenseTypes.Bsd3,
+      packageLicenseFile: "LICENSE"
+    },
+  },
+  {
+    name: "lit-html",
+    license: {
+      name: "lit-html",
+      type: LicenseTypes.Bsd3,
+      packageLicenseFile: "LICENSE"
+    },
+  },
+  {
+    name: "tslib",
+    license: {
+      name: "tslib",
+      type: LicenseTypes.BsdZeroClause,
+      packageLicenseFile: "LICENSE.txt"
+    },
+    nonPackages: ["modules", "test/validateModuleExportsMatchCommonJS"],
+  },
 ];
 
 export default packages;
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2016.txt b/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2016.txt
new file mode 100644
index 0000000..077af5e
--- /dev/null
+++ b/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2016.txt
@@ -0,0 +1,34 @@
+Copyright (c) 2016 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index a67bf4a..fa59260 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -5,32 +5,40 @@
   "dependencies": {
     "@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-a11y-announcer": "^3.0.1",
     "@polymer/iron-autogrow-textarea": "^3.0.3",
     "@polymer/iron-dropdown": "^3.0.1",
-    "@polymer/iron-fit-behavior": "^3.0.2",
+    "@polymer/iron-fit-behavior": "^3.1.0",
     "@polymer/iron-icon": "^3.0.1",
     "@polymer/iron-iconset-svg": "^3.0.1",
     "@polymer/iron-input": "^3.0.1",
     "@polymer/iron-overlay-behavior": "^3.0.3",
     "@polymer/iron-selector": "^3.0.1",
     "@polymer/paper-button": "^3.0.1",
+    "@polymer/paper-card": "^3.0.1",
+    "@polymer/paper-checkbox": "^3.1.0",
     "@polymer/paper-dialog": "^3.0.1",
     "@polymer/paper-dialog-behavior": "^3.0.1",
     "@polymer/paper-dialog-scrollable": "^3.0.1",
+    "@polymer/paper-dropdown-menu": "^3.2.0",
     "@polymer/paper-input": "^3.2.1",
     "@polymer/paper-item": "^3.0.1",
     "@polymer/paper-listbox": "^3.0.1",
+    "@polymer/paper-styles": "^3.0.1",
     "@polymer/paper-tabs": "^3.1.0",
     "@polymer/paper-toggle-button": "^3.0.1",
+    "@polymer/paper-tooltip": "^3.0.1",
     "@polymer/polymer": "^3.4.1",
-    "@webcomponents/shadycss": "^1.9.2",
+    "@types/resize-observer-browser": "^0.1.5",
+    "@webcomponents/shadycss": "^1.10.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
     "ba-linkify": "file:../../lib/ba-linkify/src/",
-    "page": "^1.11.5",
+    "lit-element": "^2.4.0",
+    "page": "^1.11.6",
     "polymer-bridges": "file:../../polymer-bridges/",
-    "polymer-resin": "^2.0.1"
+    "polymer-resin": "^2.0.1",
+    "rxjs": "^6.6.7"
   },
   "license": "Apache-2.0",
   "private": true
diff --git a/polygerrit-ui/app/polymer.json b/polygerrit-ui/app/polymer.json
index 11793e2..02ffd26 100644
--- a/polygerrit-ui/app/polymer.json
+++ b/polygerrit-ui/app/polymer.json
@@ -9,6 +9,10 @@
   ],
   "lint": {
     "rules": ["polymer-3"],
-    "ignoreWarnings": ["deprecated-dom-call"]
+    "ignoreWarnings": ["deprecated-dom-call"],
+    "filesToIgnore": [
+        "**/gr-plugin-rest-api.js",
+        "**/.cache/**/gr-plugin-rest-api.js"
+    ]
   }
 }
diff --git a/polygerrit-ui/app/samples/add-from-favorite-reviewers.js b/polygerrit-ui/app/samples/add-from-favorite-reviewers.js
index 76b2787..1616ef3 100644
--- a/polygerrit-ui/app/samples/add-from-favorite-reviewers.js
+++ b/polygerrit-ui/app/samples/add-from-favorite-reviewers.js
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 /**
  * This plugin will a button to quickly add favorite reviewers to
  * reviewers in reply dialog.
@@ -142,4 +143,4 @@
       'reply-reviewers', ReviewerShortcut.is, {slot: 'right'});
   plugin.registerCustomComponent(
       'reply-reviewers', ReviewerShortcutContent.is, {slot: 'below'});
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/samples/bind-parameters.js b/polygerrit-ui/app/samples/bind-parameters.js
index 30c7c3d..4527a80 100644
--- a/polygerrit-ui/app/samples/bind-parameters.js
+++ b/polygerrit-ui/app/samples/bind-parameters.js
@@ -14,15 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-// Element class exists in all browsers:
-// https://developer.mozilla.org/en-US/docs/Web/API/Element
-// Rename it to PolymerElement to avoid conflicts. Also,
-// typescript reports the following error:
-// error TS2451: Cannot redeclare block-scoped variable 'Element'.
-const {html, Element: PolymerElement} = Polymer;
-
-class MyBindSample extends PolymerElement {
+class MyBindSample extends Polymer.Element {
   static get is() { return 'my-bind-sample'; }
 
   static get properties() {
@@ -39,7 +31,7 @@
   }
 
   static get template() {
-    return html`
+    return Polymer.html`
     Template example: Patchset number [[revision._number]]. <br/>
     Computed example: [[computedExample]].
     `;
diff --git a/polygerrit-ui/app/samples/coverage-plugin.js b/polygerrit-ui/app/samples/coverage-plugin.js
deleted file mode 100644
index 9b2b687..0000000
--- a/polygerrit-ui/app/samples/coverage-plugin.js
+++ /dev/null
@@ -1,82 +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.
- */
-function populateWithDummyData(coverageData) {
-  coverageData['/COMMIT_MSG'] = {
-    linesMissingCoverage: [3, 4, 7, 14],
-    totalLines: 14,
-    changeNum: 94,
-    patchNum: 2,
-  };
-
-  // more coverage info on other files
-}
-
-/**
- * This plugin will add a toggler on file diff page to
- * display fake coverage data.
- *
- * As the fake coverage data only provided for COMMIT_MSG file,
- * so it will only work for COMMIT_MSG file diff.
- */
-Gerrit.install(plugin => {
-  const coverageData = {};
-  let displayCoverage = false;
-  const annotationApi = plugin.annotationApi();
-  const styleApi = plugin.styles();
-
-  const coverageStyle = styleApi.css('background-color: #EF9B9B !important');
-  const emptyStyle = styleApi.css('');
-
-  annotationApi.addLayer(context => {
-    if (Object.keys(coverageData).length === 0) {
-      // Coverage data is not ready yet.
-      return;
-    }
-    const path = context.path;
-    const line = context.line;
-    // Highlight lines missing coverage with this background color if
-    // coverage should be displayed, else do nothing.
-    const annotationStyle = displayCoverage
-      ? coverageStyle
-      : emptyStyle;
-
-    // ideally should check to make sure its the same patch for same change
-    // for demo purpose, this is only checking to make sure we have fake data
-    if (coverageData[path]) {
-      const linesMissingCoverage = coverageData[path].linesMissingCoverage;
-      if (linesMissingCoverage.includes(line.afterNumber)) {
-        context.annotateRange(0, line.text.length, annotationStyle, 'right');
-        context.annotateLineNumber(annotationStyle, 'right');
-      }
-    }
-  }).enableToggleCheckbox('Display Coverage', checkbox => {
-    // Checkbox is attached so now add the notifier that will be controlled
-    // by the checkbox.
-    // Checkbox will only be added to the file diff page, in the top right
-    // section near the "Diff view".
-    annotationApi.addNotifier(notifyFunc => {
-      populateWithDummyData(coverageData);
-      checkbox.disabled = false;
-      checkbox.onclick = e => {
-        displayCoverage = e.target.checked;
-        Object.keys(coverageData).forEach(file => {
-          notifyFunc(file, 0, coverageData[file].totalLines, 'right');
-        });
-      };
-    });
-  });
-});
\ No newline at end of file
diff --git a/polygerrit-ui/app/samples/extra-column-on-file-list.js b/polygerrit-ui/app/samples/extra-column-on-file-list.js
index 2e37c01..c64bcd4 100644
--- a/polygerrit-ui/app/samples/extra-column-on-file-list.js
+++ b/polygerrit-ui/app/samples/extra-column-on-file-list.js
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 /**
  * This plugin will an extra column to file list on change page to show
  * the first character of the path.
@@ -74,4 +75,4 @@
       'change-view-file-list-header-prepend', ColumnHeader.is);
   plugin.registerDynamicCustomComponent(
       'change-view-file-list-content-prepend', ColumnContent.is);
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/samples/lgtm-plugin.js b/polygerrit-ui/app/samples/lgtm-plugin.js
index 9de1496..537b1fa 100644
--- a/polygerrit-ui/app/samples/lgtm-plugin.js
+++ b/polygerrit-ui/app/samples/lgtm-plugin.js
@@ -14,8 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 /**
- * This plugin will +1 on Code-Review label if detect that you have
+ * This plugin will +1 on Code-Review label if it detects that you have
  * LGTM as start of your reply.
  */
 Gerrit.install(plugin => {
@@ -29,4 +30,4 @@
       replyApi.setLabelValue(label, '+1');
     }
   });
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/samples/repo-command.js b/polygerrit-ui/app/samples/repo-command.js
index 4f64059..acecd7d 100644
--- a/polygerrit-ui/app/samples/repo-command.js
+++ b/polygerrit-ui/app/samples/repo-command.js
@@ -15,14 +15,7 @@
  * limitations under the License.
  */
 
-// Element class exists in all browsers:
-// https://developer.mozilla.org/en-US/docs/Web/API/Element
-// Rename it to PolymerElement to avoid conflicts. Also,
-// typescript reports the following error:
-// error TS2451: Cannot redeclare block-scoped variable 'Element'.
-const {html, Element: PolymerElement} = Polymer;
-
-class RepoCommandLow extends PolymerElement {
+class RepoCommandLow extends Polymer.Element {
   static get is() { return 'repo-command-low'; }
 
   static get properties() {
@@ -32,20 +25,16 @@
   }
 
   static get template() {
-    return html`
-    <style include="shared-styles">
-    :host {
-      display: block;
-      margin-bottom: var(--spacing-xxl);
-    }
-    </style>
-    <h3>Low-level bork</h3>
-    <gr-button
-      on-click="_handleCommandTap"
-    >
-      Low-level bork
-    </gr-button>
-   `;
+    return Polymer.html`
+      <style include="shared-styles">
+      :host {
+        display: block;
+        margin-bottom: var(--spacing-xxl);
+      }
+      </style>
+      <h3>Plugin Bork</h3>
+      <gr-button on-click="_handleCommandTap">Bork</gr-button>
+    `;
   }
 
   connectedCallback() {
@@ -56,32 +45,16 @@
   }
 
   _handleCommandTap() {
-    alert('(softly) bork, bork.');
+    alert('bork');
   }
 }
 
-// register the custom component
 customElements.define(RepoCommandLow.is, RepoCommandLow);
 
 /**
- * This plugin will add two new commands in command page for
- * All-Projects.
- *
- * The added commands will simply alert you when click.
+ * This plugin adds a new command to the command page of the repo All-Projects.
  */
 Gerrit.install(plugin => {
-  // High-level API
-  plugin.project()
-      .createCommand('Bork', (repoName, projectConfig) => {
-        if (repoName !== 'All-Projects') {
-          return false;
-        }
-      })
-      .onTap(() => {
-        alert('Bork, bork!');
-      });
-
-  // Low-level API
   plugin.registerCustomComponent(
       'repo-command', 'repo-command-low');
 });
diff --git a/polygerrit-ui/app/samples/some-screen.js b/polygerrit-ui/app/samples/some-screen.js
index c600fe4..8edaaa9 100644
--- a/polygerrit-ui/app/samples/some-screen.js
+++ b/polygerrit-ui/app/samples/some-screen.js
@@ -15,14 +15,7 @@
  * limitations under the License.
  */
 
-// Element class exists in all browsers:
-// https://developer.mozilla.org/en-US/docs/Web/API/Element
-// Rename it to PolymerElement to avoid conflicts. Also,
-// typescript reports the following error:
-// error TS2451: Cannot redeclare block-scoped variable 'Element'.
-const {html, Element: PolymerElement} = Polymer;
-
-class SomeScreenMain extends PolymerElement {
+class SomeScreenMain extends Polymer.Element {
   static get is() { return 'some-screen-main'; }
 
   static get properties() {
@@ -32,7 +25,7 @@
   }
 
   static get template() {
-    return html`
+    return Polymer.html`
       This is the <b>main</b> plugin screen at [[token]]
       <ul>
         <li><a href$="[[rootUrl]]/bar">without component</a></li>
@@ -46,7 +39,6 @@
   }
 }
 
-// register the custom component
 customElements.define(SomeScreenMain.is, SomeScreenMain);
 
 /**
diff --git a/polygerrit-ui/app/samples/suggest-vote.js b/polygerrit-ui/app/samples/suggest-vote.js
index b3c3046..10d0d4a 100644
--- a/polygerrit-ui/app/samples/suggest-vote.js
+++ b/polygerrit-ui/app/samples/suggest-vote.js
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 /**
  * This plugin will upgrade your +1 on Code-Review label
  * to +2 and show a message below the voting labels.
@@ -29,8 +30,7 @@
     if (wasSuggested && name === CODE_REVIEW) {
       replyApi.showMessage('');
       wasSuggested = false;
-    } else if (replyApi.getLabelValue(CODE_REVIEW) === '+1' &&
-    !wasSuggested) {
+    } else if (replyApi.getLabelValue(CODE_REVIEW) === '+1' && !wasSuggested) {
       replyApi.setLabelValue(CODE_REVIEW, '+2');
       replyApi.showMessage(`Suggested ${CODE_REVIEW} upgrade: +2`);
       wasSuggested = true;
diff --git a/polygerrit-ui/app/scripts/bundled-polymer.ts b/polygerrit-ui/app/scripts/bundled-polymer.ts
index a52cc6b..75c99d5 100644
--- a/polygerrit-ui/app/scripts/bundled-polymer.ts
+++ b/polygerrit-ui/app/scripts/bundled-polymer.ts
@@ -26,8 +26,3 @@
 // of the polymer.html file.
 
 import './js/bundled-polymer-bridges';
-
-import {importHref} from './import-href';
-
-window.Polymer = window.Polymer || {};
-window.Polymer.importHref = importHref;
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
index e555ebe..5818003 100644
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
@@ -15,14 +15,14 @@
  * limitations under the License.
  */
 import {getAccountDisplayName} from '../../utils/display-name-util';
-import {RestApiService} from '../../services/services/gr-rest-api/gr-rest-api';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {AccountInfo} from '../../types/common';
 
 export class GrEmailSuggestionsProvider {
-  constructor(private _restAPI: RestApiService) {}
+  constructor(private restAPI: RestApiService) {}
 
   getSuggestions(input: string) {
-    return this._restAPI.getSuggestedAccounts(`${input}`).then(accounts => {
+    return this.restAPI.getSuggestedAccounts(`${input}`).then(accounts => {
       if (!accounts) {
         return [];
       }
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
index 7c40b7a..66681ee 100644
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
@@ -16,16 +16,11 @@
  */
 
 import '../../test/common-test-setup-karma.js';
-import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import {GrEmailSuggestionsProvider} from './gr-email-suggestions-provider.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`);
+import {appContext} from '../../services/app-context.js';
+import {stubRestApi} from '../../test/test-utils.js';
 
 suite('GrEmailSuggestionsProvider tests', () => {
-  let restAPI;
   let provider;
   const account1 = {
     name: 'Some name',
@@ -37,17 +32,13 @@
   };
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
-    restAPI = basicFixture.instantiate();
-    provider = new GrEmailSuggestionsProvider(restAPI);
+    provider = new GrEmailSuggestionsProvider(appContext.restApiService);
   });
 
   test('getSuggestions', done => {
     const getSuggestedAccountsStub =
-        sinon.stub(restAPI, 'getSuggestedAccounts')
-            .returns(Promise.resolve([account1, account2]));
+        stubRestApi('getSuggestedAccounts').returns(
+            Promise.resolve([account1, account2]));
 
     provider.getSuggestions('Some input').then(res => {
       assert.deepEqual(res, [account1, account2]);
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
index df77c76..ff113fb 100644
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
@@ -15,14 +15,14 @@
  * limitations under the License.
  */
 
-import {RestApiService} from '../../services/services/gr-rest-api/gr-rest-api';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {GroupBaseInfo} from '../../types/common';
 
 export class GrGroupSuggestionsProvider {
-  constructor(private _restAPI: RestApiService) {}
+  constructor(private restAPI: RestApiService) {}
 
   getSuggestions(input: string) {
-    return this._restAPI.getSuggestedGroups(`${input}`).then(groups => {
+    return this.restAPI.getSuggestedGroups(`${input}`).then(groups => {
       if (!groups) {
         return [];
       }
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
index 0939f76..1a14abf 100644
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
+++ b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
@@ -16,16 +16,11 @@
  */
 
 import '../../test/common-test-setup-karma.js';
-import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import {GrGroupSuggestionsProvider} from './gr-group-suggestions-provider.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`);
+import {appContext} from '../../services/app-context.js';
+import {stubRestApi} from '../../test/test-utils.js';
 
 suite('GrGroupSuggestionsProvider tests', () => {
-  let restAPI;
   let provider;
   const group1 = {
     name: 'Some name',
@@ -38,16 +33,12 @@
   };
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
-    restAPI = basicFixture.instantiate();
-    provider = new GrGroupSuggestionsProvider(restAPI);
+    provider = new GrGroupSuggestionsProvider(appContext.restApiService);
   });
 
   test('getSuggestions', done => {
     const getSuggestedAccountsStub =
-        sinon.stub(restAPI, 'getSuggestedGroups')
+        stubRestApi('getSuggestedGroups')
             .returns(Promise.resolve({
               'Some name': {id: 1},
               'Other name': {id: 3, url: 'abcd'},
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
index 6ab69bb..45116aa 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -18,7 +18,7 @@
   getAccountDisplayName,
   getGroupDisplayName,
 } from '../../utils/display-name-util';
-import {RestApiService} from '../../services/services/gr-rest-api/gr-rest-api';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {
   AccountInfo,
   isReviewerAccountSuggestion,
@@ -73,13 +73,13 @@
     }
   }
 
-  private _initPromise?: Promise<void>;
+  private initPromise?: Promise<void>;
 
-  private _config?: ServerInfo;
+  private config?: ServerInfo;
 
-  private _loggedIn = false;
+  private loggedIn = false;
 
-  private _initialized = false;
+  private initialized = false;
 
   private constructor(
     private readonly _restAPI: RestApiService,
@@ -87,26 +87,25 @@
   ) {}
 
   init() {
-    if (this._initPromise) {
-      return this._initPromise;
+    if (this.initPromise) {
+      return this.initPromise;
     }
     const getConfigPromise = this._restAPI.getConfig().then(cfg => {
-      this._config = cfg;
+      this.config = cfg;
     });
     const getLoggedInPromise = this._restAPI.getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
+      this.loggedIn = loggedIn;
     });
-    this._initPromise = Promise.all([
-      getConfigPromise,
-      getLoggedInPromise,
-    ]).then(() => {
-      this._initialized = true;
-    });
-    return this._initPromise;
+    this.initPromise = Promise.all([getConfigPromise, getLoggedInPromise]).then(
+      () => {
+        this.initialized = true;
+      }
+    );
+    return this.initPromise;
   }
 
   getSuggestions(input: string): Promise<Suggestion[]> {
-    if (!this._initialized || !this._loggedIn) {
+    if (!this.initialized || !this.loggedIn) {
       return Promise.resolve([]);
     }
 
@@ -117,7 +116,7 @@
     if (isReviewerAccountSuggestion(suggestion)) {
       // Reviewer is an account suggestion from getChangeSuggestedReviewers.
       return {
-        name: getAccountDisplayName(this._config, suggestion.account),
+        name: getAccountDisplayName(this.config, suggestion.account),
         value: suggestion,
       };
     }
@@ -133,7 +132,7 @@
     if (isAccountSuggestions(suggestion)) {
       // Reviewer is an account suggestion from getSuggestedAccounts.
       return {
-        name: getAccountDisplayName(this._config, suggestion),
+        name: getAccountDisplayName(this.config, suggestion),
         value: {account: suggestion, count: 1},
       };
     }
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
index fe13c1c..d3cad45 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
@@ -16,13 +16,9 @@
  */
 
 import '../../test/common-test-setup-karma.js';
-import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from './gr-reviewer-suggestions-provider.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`);
+import {appContext} from '../../services/app-context.js';
+import {stubRestApi} from '../../test/test-utils.js';
 
 suite('GrReviewerSuggestionsProvider tests', () => {
   let _nextAccountId = 0;
@@ -51,7 +47,6 @@
   let suggestion1;
   let suggestion2;
   let suggestion3;
-  let restAPI;
   let provider;
 
   let redundantSuggestion1;
@@ -72,12 +67,8 @@
       },
     };
 
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-      getConfig() { return Promise.resolve({}); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({}));
 
-    restAPI = basicFixture.instantiate();
     change = {
       _number: 42,
       owner,
@@ -92,21 +83,23 @@
 
   suite('allowAnyUser set to false', () => {
     setup(done => {
-      provider = GrReviewerSuggestionsProvider.create(restAPI, change._number,
+      provider = GrReviewerSuggestionsProvider.create(
+          appContext.restApiService, change._number,
           SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
       provider.init().then(done);
     });
     suite('stubbed values for _getReviewerSuggestions', () => {
+      let getChangeSuggestedReviewersStub;
       setup(() => {
-        stub('gr-rest-api-interface', {
-          getChangeSuggestedReviewers() {
-            redundantSuggestion1 = {account: existingReviewer1};
-            redundantSuggestion2 = {account: existingReviewer2};
-            redundantSuggestion3 = {account: owner};
-            return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
-              redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
-          },
-        });
+        getChangeSuggestedReviewersStub =
+            stubRestApi('getChangeSuggestedReviewers').callsFake(() => {
+              redundantSuggestion1 = {account: existingReviewer1};
+              redundantSuggestion2 = {account: existingReviewer2};
+              redundantSuggestion3 = {account: owner};
+              return Promise.resolve([
+                redundantSuggestion1, redundantSuggestion2,
+                redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
+            });
       });
 
       test('makeSuggestionItem formats account or group accordingly', () => {
@@ -137,7 +130,7 @@
           value: {account: {}},
         });
 
-        provider._config = {
+        provider.config = {
           user: {
             anonymous_coward_name: 'Anonymous Coward Name',
           },
@@ -186,26 +179,22 @@
       });
 
       test('getSuggestions short circuits when logged out', () => {
-        // API call is already stubbed.
-        const xhrSpy = restAPI.getChangeSuggestedReviewers;
-        provider._loggedIn = false;
+        provider.loggedIn = false;
         return provider.getSuggestions('').then(() => {
-          assert.isFalse(xhrSpy.called);
-          provider._loggedIn = true;
+          assert.isFalse(getChangeSuggestedReviewersStub.called);
+          provider.loggedIn = true;
           return provider.getSuggestions('').then(() => {
-            assert.isTrue(xhrSpy.called);
+            assert.isTrue(getChangeSuggestedReviewersStub.called);
           });
         });
       });
     });
 
     test('getChangeSuggestedReviewers is used', done => {
-      const suggestReviewerStub =
-          sinon.stub(restAPI, 'getChangeSuggestedReviewers')
-              .returns(Promise.resolve([]));
-      const suggestAccountStub =
-          sinon.stub(restAPI, 'getSuggestedAccounts')
-              .returns(Promise.resolve([]));
+      const suggestReviewerStub = stubRestApi('getChangeSuggestedReviewers')
+          .returns(Promise.resolve([]));
+      const suggestAccountStub = stubRestApi('getSuggestedAccounts')
+          .returns(Promise.resolve([]));
 
       provider.getSuggestions('').then(() => {
         assert.isTrue(suggestReviewerStub.calledOnce);
@@ -218,18 +207,17 @@
 
   suite('allowAnyUser set to true', () => {
     setup(done => {
-      provider = GrReviewerSuggestionsProvider.create(restAPI, change._number,
+      provider = GrReviewerSuggestionsProvider.create(
+          appContext.restApiService, change._number,
           SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
       provider.init().then(done);
     });
 
     test('getSuggestedAccounts is used', done => {
-      const suggestReviewerStub =
-          sinon.stub(restAPI, 'getChangeSuggestedReviewers')
-              .returns(Promise.resolve([]));
-      const suggestAccountStub =
-          sinon.stub(restAPI, 'getSuggestedAccounts')
-              .returns(Promise.resolve([]));
+      const suggestReviewerStub = stubRestApi('getChangeSuggestedReviewers')
+          .returns(Promise.resolve([]));
+      const suggestAccountStub = stubRestApi('getSuggestedAccounts')
+          .returns(Promise.resolve([]));
 
       provider.getSuggestions('').then(() => {
         assert.isFalse(suggestReviewerStub.called);
diff --git a/polygerrit-ui/app/scripts/import-href.ts b/polygerrit-ui/app/scripts/import-href.ts
deleted file mode 100644
index 3249c56..0000000
--- a/polygerrit-ui/app/scripts/import-href.ts
+++ /dev/null
@@ -1,119 +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.
- */
-
-// This file is a replacement for the
-// polymer-bridges/polymer/lib/utils/import-href.html file. The html
-// file contains code inside <script>...</script> and can't be imported
-// in es6 modules.
-
-interface ImportHrefElement extends HTMLLinkElement {
-  __dynamicImportLoaded?: boolean;
-}
-
-// run a callback when HTMLImports are ready or immediately if
-// this api is not available.
-function whenImportsReady(cb: () => void) {
-  const win = window as Window;
-  if (win.HTMLImports) {
-    win.HTMLImports.whenReady(cb);
-  } else {
-    cb();
-  }
-}
-
-/**
- * Convenience method for importing an HTML document imperatively.
- *
- * This method creates a new `<link rel="import">` element with
- * the provided URL and appends it to the document to start loading.
- * In the `onload` callback, the `import` property of the `link`
- * element will contain the imported document contents.
- *
- * @memberof Polymer
- * @param href URL to document to load.
- * @param onload Callback to notify when an import successfully
- *   loaded.
- * @param onerror Callback to notify when an import
- *   unsuccessfully loaded.
- * @param async True if the import should be loaded `async`.
- *   Defaults to `false`.
- * @return The link element for the URL to be loaded.
- */
-export function importHref(
-  href: string,
-  onload: (e: Event) => void,
-  onerror: (e: Event) => void,
-  async = false
-): HTMLLinkElement {
-  let link = document.head.querySelector(
-    'link[href="' + href + '"][import-href]'
-  ) as ImportHrefElement;
-  if (!link) {
-    link = document.createElement('link') as ImportHrefElement;
-    link.setAttribute('rel', 'import');
-    link.setAttribute('href', href);
-    link.setAttribute('import-href', '');
-  }
-  // always ensure link has `async` attribute if user specified one,
-  // even if it was previously not async. This is considered less confusing.
-  if (async) {
-    link.setAttribute('async', '');
-  }
-  // NOTE: the link may now be in 3 states: (1) pending insertion,
-  // (2) inflight, (3) already loaded. In each case, we need to add
-  // event listeners to process callbacks.
-  const cleanup = function () {
-    link.removeEventListener('load', loadListener);
-    link.removeEventListener('error', errorListener);
-  };
-  const loadListener = function (event: Event) {
-    cleanup();
-    // In case of a successful load, cache the load event on the link so
-    // that it can be used to short-circuit this method in the future when
-    // it is called with the same href param.
-    link.__dynamicImportLoaded = true;
-    if (onload) {
-      whenImportsReady(() => {
-        onload(event);
-      });
-    }
-  };
-  const errorListener = function (event: Event) {
-    cleanup();
-    // In case of an error, remove the link from the document so that it
-    // will be automatically created again the next time `importHref` is
-    // called.
-    if (link.parentNode) {
-      link.parentNode.removeChild(link);
-    }
-    if (onerror) {
-      whenImportsReady(() => {
-        onerror(event);
-      });
-    }
-  };
-  link.addEventListener('load', loadListener);
-  link.addEventListener('error', errorListener);
-  if (link.parentNode === null) {
-    document.head.appendChild(link);
-    // if the link already loaded, dispatch a fake load event
-    // so that listeners are called and get a proper event argument.
-  } else if (link.__dynamicImportLoaded) {
-    link.dispatchEvent(new Event('load'));
-  }
-  return link;
-}
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index b249d16..dab894e 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -19,12 +19,18 @@
 import {GrReporting} from './gr-reporting/gr-reporting_impl';
 import {EventEmitter} from './gr-event-interface/gr-event-interface_impl';
 import {Auth} from './gr-auth/gr-auth_impl';
+import {GrRestApiInterface} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface';
+import {ChangeService} from './change/change-service';
+import {ChecksService} from './checks/checks-service';
+import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
+import {GrStorageService} from './storage/gr-storage_impl';
+import {ConfigService} from './config/config-service';
 
 type ServiceName = keyof AppContext;
 type ServiceCreator<T> = () => T;
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-const initializedServices: Map<ServiceName, any> = new Map();
+const initializedServices: Map<ServiceName, any> = new Map<ServiceName, any>();
 
 function getService<K extends ServiceName>(
   serviceName: K,
@@ -65,5 +71,11 @@
     reportingService: () => new GrReporting(appContext.flagsService),
     eventEmitter: () => new EventEmitter(),
     authService: () => new Auth(appContext.eventEmitter),
+    restApiService: () => new GrRestApiInterface(appContext.authService),
+    changeService: () => new ChangeService(),
+    checksService: () => new ChecksService(),
+    jsApiService: () => new GrJsApiInterface(),
+    storageService: () => new GrStorageService(),
+    configService: () => new ConfigService(),
   });
 }
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index c08ee7a..fbc3115 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -18,12 +18,24 @@
 import {EventEmitterService} from './gr-event-interface/gr-event-interface';
 import {ReportingService} from './gr-reporting/gr-reporting';
 import {AuthService} from './gr-auth/gr-auth';
+import {RestApiService} from './gr-rest-api/gr-rest-api';
+import {ChangeService} from './change/change-service';
+import {ChecksService} from './checks/checks-service';
+import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
+import {StorageService} from './storage/gr-storage';
+import {ConfigService} from './config/config-service';
 
 export interface AppContext {
   flagsService: FlagsService;
   reportingService: ReportingService;
   eventEmitter: EventEmitterService;
   authService: AuthService;
+  restApiService: RestApiService;
+  changeService: ChangeService;
+  checksService: ChecksService;
+  jsApiService: JsApiService;
+  storageService: StorageService;
+  configService: ConfigService;
 }
 
 /**
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
new file mode 100644
index 0000000..7085d3c
--- /dev/null
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -0,0 +1,116 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {PatchSetNum} from '../../types/common';
+import {BehaviorSubject, combineLatest, Observable} from 'rxjs';
+import {
+  map,
+  filter,
+  withLatestFrom,
+  distinctUntilChanged,
+} from 'rxjs/operators';
+import {routerPatchNum$, routerState$} from '../router/router-model';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+} from '../../utils/patch-set-util';
+import {ParsedChangeInfo} from '../../types/types';
+
+interface ChangeState {
+  change?: ParsedChangeInfo;
+}
+
+// TODO: Figure out how to best enforce immutability of all states. Use Immer?
+// Use DeepReadOnly?
+const initialState: ChangeState = {};
+
+const privateState$ = new BehaviorSubject(initialState);
+
+// Re-exporting as Observable so that you can only subscribe, but not emit.
+export const changeState$: Observable<ChangeState> = privateState$;
+
+// Must only be used by the change service or whatever is in control of this
+// model.
+export function updateState(change?: ParsedChangeInfo) {
+  const current = privateState$.getValue();
+  // We want to make it easy for subscribers to react to change changes, so we
+  // are explicitly emitting and additional `undefined` when the change number
+  // changes. So if you are subscribed to the latestPatchsetNumber for example,
+  // then you can rely on emissions even if the old and the new change have the
+  // same latestPatchsetNumber.
+  if (change !== undefined && current.change !== undefined) {
+    if (change._number !== current.change._number) {
+      privateState$.next({...current, change: undefined});
+    }
+  }
+  privateState$.next({...current, change});
+}
+
+/**
+ * If you depend on both, router and change state, then you want to filter out
+ * inconsistent state, e.g. router changeNum already updated, change not yet
+ * reset to undefined.
+ */
+export const changeAndRouterConsistent$ = combineLatest([
+  routerState$,
+  changeState$,
+]).pipe(
+  filter(([routerState, changeState]) => {
+    const changeNum = changeState.change?._number;
+    const routerChangeNum = routerState.changeNum;
+    return changeNum === undefined || changeNum === routerChangeNum;
+  }),
+  distinctUntilChanged()
+);
+
+export const change$ = changeState$.pipe(
+  map(changeState => changeState.change),
+  distinctUntilChanged()
+);
+
+export const changeNum$ = change$.pipe(
+  map(change => change?._number),
+  distinctUntilChanged()
+);
+
+export const repo$ = change$.pipe(
+  map(change => change?.project),
+  distinctUntilChanged()
+);
+
+export const latestPatchNum$ = change$.pipe(
+  map(change => computeLatestPatchNum(computeAllPatchSets(change))),
+  distinctUntilChanged()
+);
+
+/**
+ * Emits the current patchset number. If the route does not define the current
+ * patchset num, then this selector waits for the change to be defined and
+ * returns the number of the latest patchset.
+ *
+ * Note that this selector can emit a patchNum without the change being
+ * available!
+ */
+export const currentPatchNum$: Observable<
+  PatchSetNum | undefined
+> = changeAndRouterConsistent$.pipe(
+  withLatestFrom(routerPatchNum$, latestPatchNum$),
+  map(
+    ([_, routerPatchNum, latestPatchNum]) => routerPatchNum || latestPatchNum
+  ),
+  distinctUntilChanged()
+);
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
new file mode 100644
index 0000000..c292fb5
--- /dev/null
+++ b/polygerrit-ui/app/services/change/change-service.ts
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {routerChangeNum$} from '../router/router-model';
+import {updateState} from './change-model';
+import {ParsedChangeInfo} from '../../types/types';
+
+export class ChangeService {
+  constructor() {
+    // TODO: In the future we will want to make restApiService.getChangeDetail()
+    // calls from a switchMap() here. For now just make sure to invalidate the
+    // change when no changeNum is set.
+    routerChangeNum$.subscribe(changeNum => {
+      if (!changeNum) updateState(undefined);
+    });
+  }
+
+  /**
+   * This is a temporary indirection between change-view, which currently
+   * manages what the current change is, and the change-model, which will
+   * become the source of truth in the future. We will extract a substantial
+   * amount of code from change-view and move it into this change-service. This
+   * will take some time ...
+   */
+  updateChange(change: ParsedChangeInfo) {
+    updateState(change);
+  }
+}
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
new file mode 100644
index 0000000..ecd56cb
--- /dev/null
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -0,0 +1,578 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {BehaviorSubject, Observable} from 'rxjs';
+import {
+  Action,
+  Category,
+  CheckResult as CheckResultApi,
+  CheckRun as CheckRunApi,
+  ChecksApiConfig,
+  Link,
+  LinkIcon,
+  RunStatus,
+} 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';
+
+export interface CheckResult extends CheckResultApi {
+  /**
+   * Internally we want to uniquely identify a run with an id, for example when
+   * efficiently re-rendering lists of runs in the UI.
+   */
+  internalResultId: string;
+}
+
+export interface CheckRun extends CheckRunApi {
+  /**
+   * Internally we want to uniquely identify a result with an id, for example
+   * when efficiently re-rendering lists of results in the UI.
+   */
+  internalRunId: string;
+  /**
+   * Is this run attempt the latest attempt for the check, i.e. does it have
+   * the highest attempt number among all checks with the same name?
+   */
+  isLatestAttempt: boolean;
+  /**
+   * Is this the only attempt for the check, i.e. we don't have data for other
+   * attempts?
+   */
+  isSingleAttempt: boolean;
+  /**
+   * List of all attempts for the same check, ordered by attempt number.
+   */
+  attemptDetails: AttemptDetail[];
+  results?: CheckResult[];
+}
+
+// This is a convenience type for working with results, because when working
+// with a bunch of results you will typically also want to know about the run
+// properties. So you can just combine them with {...run, ...result}.
+export type RunResult = CheckRun & CheckResult;
+
+interface ChecksProviderState {
+  pluginName: string;
+  loading: boolean;
+  /** 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: {
+    [name: string]: ChecksProviderState;
+  };
+}
+
+const initialState: ChecksState = {
+  providerNameToState: {},
+};
+
+const privateState$ = new BehaviorSubject(initialState);
+
+// Re-exporting as Observable so that you can only subscribe, but not emit.
+export const checksState$: Observable<ChecksState> = privateState$;
+
+export const checksPatchsetNumber$ = checksState$.pipe(
+  map(state => state.patchsetNumber),
+  distinctUntilChanged()
+);
+
+export const checksProviderState$ = checksState$.pipe(
+  map(state => state.providerNameToState),
+  distinctUntilChanged()
+);
+
+export const aPluginHasRegistered$ = checksProviderState$.pipe(
+  map(state => Object.keys(state).length > 0),
+  distinctUntilChanged()
+);
+
+export const someProvidersAreLoading$ = checksProviderState$.pipe(
+  map(state =>
+    Object.values(state).some(providerState => providerState.loading)
+  ),
+  distinctUntilChanged()
+);
+
+export const errorMessage$ = checksProviderState$.pipe(
+  map(
+    state =>
+      Object.values(state).find(
+        providerState => providerState.errorMessage !== undefined
+      )?.errorMessage
+  ),
+  distinctUntilChanged()
+);
+
+export const loginCallback$ = checksProviderState$.pipe(
+  map(
+    state =>
+      Object.values(state).find(
+        providerState => providerState.loginCallback !== undefined
+      )?.loginCallback
+  ),
+  distinctUntilChanged()
+);
+
+export const allActions$ = checksProviderState$.pipe(
+  map(state =>
+    Object.values(state).reduce(
+      (allActions: Action[], providerState: ChecksProviderState) => [
+        ...allActions,
+        ...providerState.actions,
+      ],
+      []
+    )
+  )
+);
+
+export const allLinks$ = checksProviderState$.pipe(
+  map(state =>
+    Object.values(state).reduce(
+      (allActions: Link[], providerState: ChecksProviderState) => [
+        ...allActions,
+        ...providerState.links,
+      ],
+      []
+    )
+  )
+);
+
+export const allRuns$ = checksProviderState$.pipe(
+  map(state =>
+    Object.values(state).reduce(
+      (allRuns: CheckRun[], providerState: ChecksProviderState) => [
+        ...allRuns,
+        ...providerState.runs,
+      ],
+      []
+    )
+  )
+);
+
+export const allRunsLatest$ = allRuns$.pipe(
+  map(runs => runs.filter(run => run.isLatestAttempt))
+);
+
+export const checkToPluginMap$ = checksProviderState$.pipe(
+  map(state => {
+    const map = new Map<string, string>();
+    for (const [pluginName, providerState] of Object.entries(state)) {
+      for (const run of providerState.runs) {
+        map.set(run.checkName, pluginName);
+      }
+    }
+    return map;
+  })
+);
+
+export const allResults$ = checksProviderState$.pipe(
+  map(state =>
+    Object.values(state)
+      .reduce(
+        (allResults: CheckResult[], providerState: ChecksProviderState) => [
+          ...allResults,
+          ...providerState.runs.reduce(
+            (results: CheckResult[], run: CheckRun) =>
+              results.concat(run.results ?? []),
+            []
+          ),
+        ],
+        []
+      )
+      .filter(r => r !== undefined)
+  )
+);
+
+// Must only be used by the checks service or whatever is in control of this
+// model.
+export function updateStateSetProvider(
+  pluginName: string,
+  config?: ChecksApiConfig
+) {
+  const nextState = {...privateState$.getValue()};
+  nextState.providerNameToState = {...nextState.providerNameToState};
+  nextState.providerNameToState[pluginName] = {
+    pluginName,
+    loading: false,
+    config,
+    runs: [],
+    actions: [],
+    links: [],
+  };
+  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.
+
+export const fakeRun0: CheckRun = {
+  internalRunId: 'f0',
+  checkName: 'FAKE Error Finder',
+  labelName: 'Presubmit',
+  isSingleAttempt: true,
+  isLatestAttempt: true,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f0r0',
+      category: Category.ERROR,
+      summary: 'I would like to point out this error: 1 is not equal to 2!',
+      links: [
+        {primary: true, url: 'https://www.google.com', icon: LinkIcon.EXTERNAL},
+      ],
+      tags: [{name: 'OBSOLETE'}, {name: 'E2E'}],
+    },
+    {
+      internalResultId: 'f0r1',
+      category: Category.ERROR,
+      summary: 'Running the mighty test has failed by crashing.',
+      actions: [
+        {
+          name: 'Ignore',
+          tooltip: 'Ignore this result',
+          primary: true,
+          callback: () => undefined,
+        },
+        {
+          name: 'Flag',
+          tooltip: 'Flag this result as not useful',
+          primary: true,
+          callback: () => undefined,
+        },
+        {
+          name: 'Upload',
+          tooltip: 'Upload the result to the super cloud.',
+          primary: false,
+          callback: () => undefined,
+        },
+      ],
+      tags: [{name: 'INTERRUPTED'}, {name: 'WINDOWS'}],
+      links: [
+        {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
+        {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},
+      ],
+    },
+  ],
+  status: RunStatus.COMPLETED,
+};
+
+export const fakeRun1: CheckRun = {
+  internalRunId: 'f1',
+  checkName: 'FAKE Super Check',
+  labelName: 'Verified',
+  isSingleAttempt: true,
+  isLatestAttempt: true,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f1r0',
+      category: Category.WARNING,
+      summary: 'We think that you could improve this.',
+      message: `There is a lot to be said. A lot. I say, a lot.\n
+                So please keep reading.`,
+      tags: [{name: 'INTERRUPTED'}, {name: 'WINDOWS'}],
+      links: [
+        {primary: true, 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},
+      ],
+    },
+  ],
+  status: RunStatus.RUNNING,
+};
+
+export const fakeRun2: CheckRun = {
+  internalRunId: 'f2',
+  checkName: 'FAKE Mega Analysis',
+  statusDescription: 'This run is nearly completed, but not quite.',
+  statusLink: 'https://www.google.com/',
+  checkDescription:
+    'From what the title says you can tell that this check analyses.',
+  checkLink: 'https://www.google.com/',
+  scheduledTimestamp: new Date('2021-04-01T03:14:15'),
+  startedTimestamp: new Date('2021-04-01T04:24:25'),
+  finishedTimestamp: new Date('2021-04-01T04:44:44'),
+  isSingleAttempt: true,
+  isLatestAttempt: true,
+  attemptDetails: [],
+  actions: [
+    {
+      name: 'Re-Run',
+      tooltip: 'More powerful run than before',
+      primary: true,
+      callback: () => undefined,
+    },
+    {
+      name: 'Monetize',
+      primary: true,
+      callback: () => undefined,
+    },
+    {
+      name: 'Delete',
+      primary: true,
+      callback: () => undefined,
+    },
+  ],
+  results: [
+    {
+      internalResultId: 'f2r0',
+      category: Category.INFO,
+      summary: 'This is looking a bit too large.',
+      message: `We are still looking into how large exactly. Stay tuned.
+And have a look at https://www.google.com!
+
+Or have a look at change 30000.
+Example code:
+  const constable = '';
+  var variable = '';`,
+      tags: [{name: 'FLAKY'}, {name: 'MAC-OS'}],
+    },
+  ],
+  status: RunStatus.COMPLETED,
+};
+
+export const fakeRun3: CheckRun = {
+  internalRunId: 'f3',
+  checkName: 'FAKE Critical Observations',
+  status: RunStatus.RUNNABLE,
+  isSingleAttempt: true,
+  isLatestAttempt: true,
+  attemptDetails: [],
+};
+
+export const fakeRun4_1: CheckRun = {
+  internalRunId: 'f4',
+  checkName: 'FAKE Elimination',
+  status: RunStatus.COMPLETED,
+  attempt: 1,
+  isSingleAttempt: false,
+  isLatestAttempt: false,
+  attemptDetails: [],
+};
+
+export const fakeRun4_2: CheckRun = {
+  internalRunId: 'f4',
+  checkName: 'FAKE Elimination',
+  status: RunStatus.COMPLETED,
+  attempt: 2,
+  isSingleAttempt: false,
+  isLatestAttempt: false,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f42r0',
+      category: Category.INFO,
+      summary: 'Please eliminate all the TODOs!',
+    },
+  ],
+};
+
+export const fakeRun4_3: CheckRun = {
+  internalRunId: 'f4',
+  checkName: 'FAKE Elimination',
+  status: RunStatus.COMPLETED,
+  attempt: 3,
+  isSingleAttempt: false,
+  isLatestAttempt: false,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f43r0',
+      category: Category.ERROR,
+      summary: 'Without eliminating all the TODOs your change will break!',
+    },
+  ],
+};
+
+export const fakeRun4_4: CheckRun = {
+  internalRunId: 'f4',
+  checkName: 'FAKE Elimination',
+  checkDescription: 'Shows you the possible eliminations.',
+  checkLink: 'https://www.google.com',
+  status: RunStatus.RUNNING,
+  statusDescription: 'Everything was eliminated already.',
+  statusLink: 'https://www.google.com',
+  attempt: 4,
+  scheduledTimestamp: new Date('2021-04-02T03:14:15'),
+  startedTimestamp: new Date('2021-04-02T04:24:25'),
+  finishedTimestamp: new Date('2021-04-02T04:25:44'),
+  isSingleAttempt: false,
+  isLatestAttempt: true,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f44r0',
+      category: Category.INFO,
+      summary: 'Dont be afraid. All TODOs will be eliminated.',
+    },
+  ],
+};
+
+export const fakeActions: Action[] = [
+  {
+    name: 'Fake Action 1',
+    primary: false,
+    tooltip: 'Tooltip for Fake Action 1',
+    callback: () => {
+      console.warn('fake action 1 triggered');
+      return undefined;
+    },
+  },
+  {
+    name: 'Fake Action 2',
+    primary: false,
+    tooltip: 'Tooltip for Fake Action 2',
+    callback: () => {
+      console.warn('fake action 2 triggered');
+      return undefined;
+    },
+  },
+  {
+    name: 'Fake Action 3',
+    primary: false,
+    tooltip: 'Tooltip for Fake Action 3',
+    callback: () => {
+      console.warn('fake action 3 triggered');
+      return undefined;
+    },
+  },
+];
+
+export const fakeLinks: Link[] = [
+  {
+    url: 'https://www.google.com',
+    primary: false,
+    tooltip: 'Tooltip for Bug Report Fake Link',
+    icon: LinkIcon.REPORT_BUG,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: false,
+    tooltip: 'Tooltip for External Fake Link',
+    icon: LinkIcon.EXTERNAL,
+  },
+];
+
+export function updateStateSetLoading(pluginName: string) {
+  const nextState = {...privateState$.getValue()};
+  nextState.providerNameToState = {...nextState.providerNameToState};
+  nextState.providerNameToState[pluginName] = {
+    ...nextState.providerNameToState[pluginName],
+    loading: true,
+  };
+  privateState$.next(nextState);
+}
+
+export function updateStateSetError(pluginName: string, errorMessage: string) {
+  const nextState = {...privateState$.getValue()};
+  nextState.providerNameToState = {...nextState.providerNameToState};
+  nextState.providerNameToState[pluginName] = {
+    ...nextState.providerNameToState[pluginName],
+    loading: false,
+    errorMessage,
+    loginCallback: undefined,
+    runs: [],
+    actions: [],
+  };
+  privateState$.next(nextState);
+}
+
+export function updateStateSetNotLoggedIn(
+  pluginName: string,
+  loginCallback: () => void
+) {
+  const nextState = {...privateState$.getValue()};
+  nextState.providerNameToState = {...nextState.providerNameToState};
+  nextState.providerNameToState[pluginName] = {
+    ...nextState.providerNameToState[pluginName],
+    loading: false,
+    errorMessage: undefined,
+    loginCallback,
+    runs: [],
+    actions: [],
+  };
+  privateState$.next(nextState);
+}
+
+export function updateStateSetResults(
+  pluginName: string,
+  runs: CheckRunApi[],
+  actions: Action[] = [],
+  links: Link[] = []
+) {
+  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));
+  }
+  const nextState = {...privateState$.getValue()};
+  nextState.providerNameToState = {...nextState.providerNameToState};
+  nextState.providerNameToState[pluginName] = {
+    ...nextState.providerNameToState[pluginName],
+    loading: false,
+    errorMessage: undefined,
+    loginCallback: undefined,
+    runs: runs.map(run => {
+      const runId = `${run.checkName}-${run.change}-${run.patchset}-${run.attempt}`;
+      const attemptInfo = attemptMap.get(run.checkName);
+      assertIsDefined(attemptInfo, 'attemptInfo');
+      return {
+        ...run,
+        internalRunId: runId,
+        isLatestAttempt: attemptInfo.latestAttempt === run.attempt,
+        isSingleAttempt: attemptInfo.isSingleAttempt,
+        attemptDetails: attemptInfo.attempts,
+        results: (run.results ?? []).map((result, i) => {
+          return {
+            ...result,
+            internalResultId: `${runId}-${i}`,
+          };
+        }),
+      };
+    }),
+    actions: [...actions],
+    links: [...links],
+  };
+  privateState$.next(nextState);
+}
+
+export function updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
+  const nextState = {...privateState$.getValue()};
+  nextState.patchsetNumber = patchsetNumber;
+  privateState$.next(nextState);
+}
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
new file mode 100644
index 0000000..250cea5
--- /dev/null
+++ b/polygerrit-ui/app/services/checks/checks-service.ts
@@ -0,0 +1,186 @@
+/**
+ * @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 {
+  catchError,
+  filter,
+  switchMap,
+  takeWhile,
+  throttleTime,
+  withLatestFrom,
+} from 'rxjs/operators';
+import {
+  ChangeData,
+  ChecksApiConfig,
+  ChecksProvider,
+  FetchResponse,
+  ResponseCode,
+} from '../../api/checks';
+import {change$, changeNum$, latestPatchNum$} from '../change/change-model';
+import {
+  checksPatchsetNumber$,
+  checkToPluginMap$,
+  updateStateSetError,
+  updateStateSetLoading,
+  updateStateSetNotLoggedIn,
+  updateStateSetPatchset,
+  updateStateSetProvider,
+  updateStateSetResults,
+} from './checks-model';
+import {
+  BehaviorSubject,
+  combineLatest,
+  from,
+  Observable,
+  of,
+  Subject,
+  timer,
+} from 'rxjs';
+import {PatchSetNumber} from '../../types/common';
+import {getCurrentRevision} from '../../utils/change-util';
+import {getShaByPatchNum} from '../../utils/patch-set-util';
+import {assertIsDefined} from '../../utils/common-util';
+
+export class ChecksService {
+  private readonly providers: {[name: string]: ChecksProvider} = {};
+
+  private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
+
+  private checkToPluginMap = new Map<string, string>();
+
+  private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
+
+  constructor() {
+    checkToPluginMap$.subscribe(map => {
+      this.checkToPluginMap = map;
+    });
+    latestPatchNum$.subscribe(num => {
+      updateStateSetPatchset(num);
+    });
+    document.addEventListener('visibilitychange', () => {
+      this.documentVisibilityChange$.next(undefined);
+    });
+  }
+
+  setPatchset(num: PatchSetNumber) {
+    updateStateSetPatchset(num);
+  }
+
+  reload(pluginName: string) {
+    this.reloadSubjects[pluginName].next();
+  }
+
+  reloadAll() {
+    Object.keys(this.providers).forEach(key => this.reload(key));
+  }
+
+  reloadForCheck(checkName?: string) {
+    if (!checkName) return;
+    const plugin = this.checkToPluginMap.get(checkName);
+    if (plugin) this.reload(plugin);
+  }
+
+  register(
+    pluginName: string,
+    provider: ChecksProvider,
+    config: ChecksApiConfig
+  ) {
+    this.providers[pluginName] = provider;
+    this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
+    updateStateSetProvider(pluginName, config);
+    const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
+    // Various events should trigger fetching checks from the provider:
+    // 1. Change number and patchset number changes.
+    // 2. Specific reload requests.
+    // 3. Regular polling starting with an initial fetch right now.
+    // 4. A hidden Gerrit tab becoming visible.
+    combineLatest([
+      changeNum$,
+      checksPatchsetNumber$,
+      this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
+      timer(0, pollIntervalMs),
+      this.documentVisibilityChange$,
+    ])
+      .pipe(
+        takeWhile(_ => !!this.providers[pluginName]),
+        filter(_ => document.visibilityState !== 'hidden'),
+        withLatestFrom(change$),
+        switchMap(
+          ([[changeNum, patchNum], change]): Observable<FetchResponse> => {
+            if (
+              !change ||
+              !changeNum ||
+              !patchNum ||
+              typeof patchNum !== 'number'
+            ) {
+              return of({
+                responseCode: ResponseCode.OK,
+                runs: [],
+              });
+            }
+            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: [],
+              });
+            }
+            const data: ChangeData = {
+              changeNumber: changeNum,
+              patchsetNumber: patchNum,
+              patchsetSha,
+              repo: change.project,
+              commmitMessage: getCurrentRevision(change)?.commit?.message,
+              changeInfo: change,
+            };
+            updateStateSetLoading(pluginName);
+            return from(this.providers[pluginName].fetch(data));
+          }
+        ),
+        catchError(e => {
+          const errorResponse: FetchResponse = {
+            responseCode: ResponseCode.ERROR,
+            errorMessage: `Error message from plugin '${pluginName}': ${e}`,
+          };
+          return of(errorResponse);
+        })
+      )
+      .subscribe(response => {
+        switch (response.responseCode) {
+          case ResponseCode.ERROR:
+            assertIsDefined(response.errorMessage, 'errorMessage');
+            updateStateSetError(pluginName, response.errorMessage);
+            break;
+          case ResponseCode.NOT_LOGGED_IN:
+            assertIsDefined(response.loginCallback, 'loginCallback');
+            updateStateSetNotLoggedIn(pluginName, response.loginCallback);
+            break;
+          case ResponseCode.OK:
+            updateStateSetResults(
+              pluginName,
+              response.runs ?? [],
+              response.actions ?? [],
+              response.links ?? []
+            );
+            break;
+        }
+      });
+  }
+}
diff --git a/polygerrit-ui/app/services/checks/checks-util.ts b/polygerrit-ui/app/services/checks/checks-util.ts
new file mode 100644
index 0000000..15841f3
--- /dev/null
+++ b/polygerrit-ui/app/services/checks/checks-util.ts
@@ -0,0 +1,307 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+  Action,
+  Category,
+  CheckRun as CheckRunApi,
+  CheckResult as CheckResultApi,
+  LinkIcon,
+  RunStatus,
+} from '../../api/checks';
+import {assertNever} from '../../utils/common-util';
+import {CheckResult, CheckRun} from './checks-model';
+
+export function iconForLink(linkIcon?: LinkIcon) {
+  if (linkIcon === undefined) return 'launch';
+  switch (linkIcon) {
+    case LinkIcon.EXTERNAL:
+      return 'launch';
+    case LinkIcon.IMAGE:
+      return 'insert-photo';
+    case LinkIcon.HISTORY:
+      return 'restore';
+    case LinkIcon.DOWNLOAD:
+      return 'download';
+    case LinkIcon.DOWNLOAD_MOBILE:
+      return 'system-update';
+    case LinkIcon.HELP_PAGE:
+      return 'help-outline';
+    case LinkIcon.REPORT_BUG:
+      return 'bug';
+    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
+      // linkIcon.
+      return 'launch';
+  }
+}
+
+export function tooltipForLink(linkIcon?: LinkIcon) {
+  if (linkIcon === undefined) return 'Link to details';
+  switch (linkIcon) {
+    case LinkIcon.EXTERNAL:
+      return 'Link to details';
+    case LinkIcon.IMAGE:
+      return 'Link to image';
+    case LinkIcon.HISTORY:
+      return 'Link to result history';
+    case LinkIcon.DOWNLOAD:
+      return 'Download';
+    case LinkIcon.DOWNLOAD_MOBILE:
+      return 'Download';
+    case LinkIcon.HELP_PAGE:
+      return 'Link to help page';
+    case LinkIcon.REPORT_BUG:
+      return 'Link for reporting a problem';
+    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
+      // linkIcon.
+      return 'Link to details';
+  }
+}
+
+export function worstCategory(run: CheckRun) {
+  if (hasResultsOf(run, Category.ERROR)) return Category.ERROR;
+  if (hasResultsOf(run, Category.WARNING)) return Category.WARNING;
+  if (hasResultsOf(run, Category.INFO)) return Category.INFO;
+  return undefined;
+}
+
+export function iconForCategory(category: Category | 'SUCCESS') {
+  switch (category) {
+    case Category.ERROR:
+      return 'error';
+    case Category.INFO:
+      return 'info-outline';
+    case Category.WARNING:
+      return 'warning';
+    case 'SUCCESS':
+      return 'check-circle-outline';
+    default:
+      assertNever(category, `Unsupported category: ${category}`);
+  }
+}
+
+enum PRIMARY_STATUS_ACTIONS {
+  RERUN = 'rerun',
+  RUN = 'run',
+  CANCEL = 'cancel',
+}
+
+export function toCanonicalAction(action: Action, status: RunStatus) {
+  let name = action.name.toLowerCase();
+  if (status === RunStatus.COMPLETED && (name === 'run' || name === 're-run')) {
+    name = PRIMARY_STATUS_ACTIONS.RERUN;
+  }
+  if (status === RunStatus.RUNNING && name === 'stop') {
+    name = PRIMARY_STATUS_ACTIONS.CANCEL;
+  }
+  return {...action, name};
+}
+
+export function primaryActionName(status: RunStatus) {
+  switch (status) {
+    case RunStatus.COMPLETED:
+      return PRIMARY_STATUS_ACTIONS.RERUN;
+    case RunStatus.RUNNABLE:
+      return PRIMARY_STATUS_ACTIONS.RUN;
+    case RunStatus.RUNNING:
+      return PRIMARY_STATUS_ACTIONS.CANCEL;
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
+  }
+}
+
+export function primaryRunAction(run: CheckRun): Action | undefined {
+  return runActions(run).filter(
+    action => action.name === primaryActionName(run.status)
+  )[0];
+}
+
+export function runActions(run?: CheckRun): Action[] {
+  if (!run?.actions) return [];
+  return run.actions.map(action => toCanonicalAction(action, run.status));
+}
+
+export function iconForRun(run: CheckRun) {
+  if (run.status !== RunStatus.COMPLETED) {
+    return iconForStatus(run.status);
+  } else {
+    const category = worstCategory(run);
+    return category ? iconForCategory(category) : iconForStatus(run.status);
+  }
+}
+
+export function iconForStatus(status: RunStatus) {
+  switch (status) {
+    // Note that this is only for COMPLETED without results!
+    case RunStatus.COMPLETED:
+      return 'check-circle-outline';
+    case RunStatus.RUNNABLE:
+      return 'placeholder';
+    case RunStatus.RUNNING:
+      return 'timelapse';
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
+  }
+}
+
+export function hasCompleted(run: CheckRun) {
+  return run.status === RunStatus.COMPLETED;
+}
+
+export function isRunning(run: CheckRun) {
+  return run.status === RunStatus.RUNNING;
+}
+
+export function isRunningOrHasCompleted(run: CheckRun) {
+  return run.status === RunStatus.COMPLETED || run.status === RunStatus.RUNNING;
+}
+
+export function hasCompletedWithoutResults(run: CheckRun) {
+  return run.status === RunStatus.COMPLETED && (run.results ?? []).length === 0;
+}
+
+export function hasCompletedWith(run: CheckRun, category: Category) {
+  return hasCompleted(run) && hasResultsOf(run, category);
+}
+
+export function hasResults(run: CheckRun): boolean {
+  return (run.results ?? []).length > 0;
+}
+
+export function allResults(runs: CheckRun[]): CheckResult[] {
+  return runs.reduce(
+    (results: CheckResult[], run: CheckRun) => [
+      ...results,
+      ...(run.results ?? []),
+    ],
+    []
+  );
+}
+
+export function hasResultsOf(run: CheckRun, category: Category) {
+  return getResultsOf(run, category).length > 0;
+}
+
+export function getResultsOf(run: CheckRun, category: Category) {
+  return (run.results ?? []).filter(r => r.category === category);
+}
+
+export function compareByWorstCategory(a: CheckRun, b: CheckRun) {
+  return level(worstCategory(b)) - level(worstCategory(a));
+}
+
+export function level(cat?: Category) {
+  if (!cat) return -1;
+  switch (cat) {
+    case Category.INFO:
+      return 0;
+    case Category.WARNING:
+      return 1;
+    case Category.ERROR:
+      return 2;
+  }
+}
+
+export interface ActionTriggeredEventDetail {
+  action: Action;
+  run?: CheckRun;
+}
+
+export type ActionTriggeredEvent = CustomEvent<ActionTriggeredEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'action-triggered': ActionTriggeredEvent;
+  }
+}
+
+export function fireActionTriggered(
+  target: EventTarget,
+  action: Action,
+  run?: CheckRun
+) {
+  target.dispatchEvent(
+    new CustomEvent('action-triggered', {
+      detail: {action, run},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export interface AttemptDetail {
+  attempt: number | undefined;
+  icon: string;
+}
+
+export interface AttemptInfo {
+  latestAttempt: number | undefined;
+  isSingleAttempt: boolean;
+  attempts: AttemptDetail[];
+}
+
+export function createAttemptMap(runs: CheckRunApi[]) {
+  const map = new Map<string, AttemptInfo>();
+  for (const run of runs) {
+    const value = map.get(run.checkName);
+    const detail = {
+      attempt: run.attempt,
+      icon: iconForRun(fromApiToInternalRun(run)),
+    };
+    if (value === undefined) {
+      map.set(run.checkName, {
+        latestAttempt: run.attempt,
+        isSingleAttempt: true,
+        attempts: [detail],
+      });
+      continue;
+    }
+    if (!run.attempt || !value.latestAttempt) {
+      throw new Error(
+        'If multiple run attempts are provided, ' +
+          'then each run must have the "attempt" property set.'
+      );
+    }
+    value.isSingleAttempt = false;
+    if (run.attempt > value.latestAttempt) {
+      value.latestAttempt = run.attempt;
+    }
+    value.attempts.push(detail);
+  }
+  return map;
+}
+
+export function fromApiToInternalRun(run: CheckRunApi): CheckRun {
+  return {
+    ...run,
+    internalRunId: 'fake',
+    isSingleAttempt: false,
+    isLatestAttempt: false,
+    attemptDetails: [],
+    results: (run.results ?? []).map(fromApiToInternalResult),
+  };
+}
+
+export function fromApiToInternalResult(result: CheckResultApi): CheckResult {
+  return {
+    ...result,
+    internalResultId: 'fake',
+  };
+}
diff --git a/polygerrit-ui/app/services/config/config-model.ts b/polygerrit-ui/app/services/config/config-model.ts
new file mode 100644
index 0000000..254760b
--- /dev/null
+++ b/polygerrit-ui/app/services/config/config-model.ts
@@ -0,0 +1,44 @@
+/**
+ * @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 {ConfigInfo} from '../../types/common';
+import {BehaviorSubject, Observable} from 'rxjs';
+import {map, distinctUntilChanged} from 'rxjs/operators';
+
+interface ConfigState {
+  repoConfig?: ConfigInfo;
+}
+
+// TODO: Figure out how to best enforce immutability of all states. Use Immer?
+// Use DeepReadOnly?
+const initialState: ConfigState = {};
+
+const privateState$ = new BehaviorSubject(initialState);
+
+// 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 const repoConfig$ = configState$.pipe(
+  map(configState => configState.repoConfig),
+  distinctUntilChanged()
+);
diff --git a/polygerrit-ui/app/services/config/config-service.ts b/polygerrit-ui/app/services/config/config-service.ts
new file mode 100644
index 0000000..d6ff99c
--- /dev/null
+++ b/polygerrit-ui/app/services/config/config-service.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.
+ */
+import {updateRepoConfig} 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 {from, of} from 'rxjs';
+
+export class ConfigService {
+  private readonly restApiService = appContext.restApiService;
+
+  constructor() {
+    repo$
+      .pipe(
+        switchMap((repo?: RepoName) => {
+          if (repo === undefined) return of(undefined);
+          return from(this.restApiService.getProjectConfig(repo));
+        })
+      )
+      .subscribe((repoConfig?: ConfigInfo) => {
+        updateRepoConfig(repoConfig);
+      });
+  }
+}
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 047e9e0..1a29055 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -24,7 +24,6 @@
  * @desc Experiment ids used in Gerrit.
  */
 export enum KnownExperimentId {
-  PATCHSET_COMMENTS = 'UiFeature__patchset_comments',
-  PATCHSET_CHOICE_FOR_COMMENT_LINKS = 'UiFeature__patchset_choice_for_comment_links',
-  NEW_CONTEXT_CONTROLS = 'UiFeature__new_context_controls',
+  NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
+  TOKEN_HIGHLIGHTING = 'UiFeature__token_highlighting',
 }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
index 8fe7c35..6fadfde 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -61,26 +61,26 @@
 
   static CREDS_EXPIRED_MSG = 'Credentials expired.';
 
-  private _authCheckPromise?: Promise<Response>;
+  private authCheckPromise?: Promise<Response>;
 
   private _last_auth_check_time: number = Date.now();
 
   private _status = AuthStatus.UNDETERMINED;
 
-  private _retriesLeft = MAX_GET_TOKEN_RETRIES;
+  private retriesLeft = MAX_GET_TOKEN_RETRIES;
 
-  private _cachedTokenPromise: Promise<Token | null> | null = null;
+  private cachedTokenPromise: Promise<Token | null> | null = null;
 
-  private _type?: AuthType;
+  private type?: AuthType;
 
-  private _defaultOptions: AuthRequestInit = {};
+  private defaultOptions: AuthRequestInit = {};
 
-  private _getToken: GetTokenCallback;
+  private getToken: GetTokenCallback;
 
   public eventEmitter: EventEmitterService;
 
   constructor(eventEmitter: EventEmitterService) {
-    this._getToken = () => Promise.resolve(this._cachedTokenPromise);
+    this.getToken = () => Promise.resolve(this.cachedTokenPromise);
     this.eventEmitter = eventEmitter;
   }
 
@@ -93,15 +93,15 @@
    */
   authCheck(): Promise<boolean> {
     if (
-      !this._authCheckPromise ||
+      !this.authCheckPromise ||
       Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS
     ) {
       // Refetch after last check expired
-      this._authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
+      this.authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
       this._last_auth_check_time = Date.now();
     }
 
-    return this._authCheckPromise
+    return this.authCheckPromise
       .then(res => {
         // auth-check will return 204 if authed
         // treat the rest as unauthed
@@ -115,14 +115,14 @@
       })
       .catch(() => {
         this._setStatus(AuthStatus.ERROR);
-        // Reset _authCheckPromise to avoid caching the failed promise
-        this._authCheckPromise = undefined;
+        // Reset authCheckPromise to avoid caching the failed promise
+        this.authCheckPromise = undefined;
         return false;
       });
   }
 
   clearCache() {
-    this._authCheckPromise = undefined;
+    this.authCheckPromise = undefined;
   }
 
   private _setStatus(status: AuthStatus) {
@@ -149,15 +149,15 @@
    * Enable cross-domain authentication using OAuth access token.
    */
   setup(getToken: GetTokenCallback, defaultOptions: DefaultAuthOptions) {
-    this._retriesLeft = MAX_GET_TOKEN_RETRIES;
+    this.retriesLeft = MAX_GET_TOKEN_RETRIES;
     if (getToken) {
-      this._type = AuthType.ACCESS_TOKEN;
-      this._cachedTokenPromise = null;
-      this._getToken = getToken;
+      this.type = AuthType.ACCESS_TOKEN;
+      this.cachedTokenPromise = null;
+      this.getToken = getToken;
     }
-    this._defaultOptions = {};
+    this.defaultOptions = {};
     if (defaultOptions) {
-      this._defaultOptions.credentials = defaultOptions.credentials;
+      this.defaultOptions.credentials = defaultOptions.credentials;
     }
   }
 
@@ -167,10 +167,10 @@
   fetch(url: string, opt_options?: AuthRequestInit): Promise<Response> {
     const options: AuthRequestInitWithHeaders = {
       headers: new Headers(),
-      ...this._defaultOptions,
+      ...this.defaultOptions,
       ...opt_options,
     };
-    if (this._type === AuthType.ACCESS_TOKEN) {
+    if (this.type === AuthType.ACCESS_TOKEN) {
       return this._getAccessToken().then(accessToken =>
         this._fetchWithAccessToken(url, options, accessToken)
       );
@@ -224,17 +224,17 @@
   }
 
   private _getAccessToken(): Promise<string | null> {
-    if (!this._cachedTokenPromise) {
-      this._cachedTokenPromise = this._getToken();
+    if (!this.cachedTokenPromise) {
+      this.cachedTokenPromise = this.getToken();
     }
-    return this._cachedTokenPromise.then(token => {
+    return this.cachedTokenPromise.then(token => {
       if (this._isTokenValid(token)) {
-        this._retriesLeft = MAX_GET_TOKEN_RETRIES;
+        this.retriesLeft = MAX_GET_TOKEN_RETRIES;
         return token.access_token;
       }
-      if (this._retriesLeft > 0) {
-        this._retriesLeft--;
-        this._cachedTokenPromise = null;
+      if (this.retriesLeft > 0) {
+        this.retriesLeft--;
+        this.cachedTokenPromise = null;
         return this._getAccessToken();
       }
       // Fall back to anonymous access.
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
index d59a022..e540029 100644
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type EventCallback = (...args: any) => void;
 export type UnsubscribeMethod = () => void;
 
@@ -51,11 +52,13 @@
    *
    * @returns true if the event had listeners, false otherwise.
    */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   emit(eventName: string, detail: any): boolean;
 
   /**
    * Alias to emit.
    */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   dispatch(eventName: string, detail: any): boolean;
 
   /**
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
index 72afbda..d8c5d77 100644
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
@@ -66,6 +66,7 @@
    * Attach event handler only once. Automatically removed.
    */
   once(eventName: string, cb: EventCallback): UnsubscribeMethod {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
     const onceWrapper = (...args: any[]) => {
       cb(...args);
       this.off(eventName, onceWrapper);
@@ -96,6 +97,7 @@
    *
    * @returns true if the event had listeners, false otherwise.
    */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   emit(eventName: string, detail: any): boolean {
     const listeners = this._listenersMap.get(eventName) || [];
     for (const listener of listeners) {
@@ -111,6 +113,7 @@
   /**
    * Alias to emit.
    */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   dispatch(eventName: string, detail: any): boolean {
     return this.emit(eventName, detail);
   }
@@ -124,7 +127,7 @@
     if (eventName) {
       this._listenersMap.set(eventName, []);
     } else {
-      this._listenersMap = new Map();
+      this._listenersMap = new Map<string, EventCallback[]>();
     }
   }
 }
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
index 32590e0..6ce5eea 100644
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
@@ -16,11 +16,8 @@
  */
 
 import '../../test/common-test-setup-karma.js';
-import '../../elements/shared/gr-js-api-interface/gr-js-api-interface.js';
 import {EventEmitter} from './gr-event-interface_impl.js';
 
-const basicFixture = fixtureFromElement('gr-js-api-interface');
-
 suite('gr-event-interface tests', () => {
   let gerrit;
   setup(() => {
@@ -29,7 +26,6 @@
 
   suite('test on Gerrit', () => {
     setup(() => {
-      basicFixture.instantiate();
       gerrit.removeAllListeners();
     });
 
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 743e0f4..d7081ce 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -15,10 +15,12 @@
  * limitations under the License.
  */
 
-export type EventValue = string | number | {error?: Error};
+import {NumericChangeId} from '../../types/common';
+import {EventDetails} from '../../api/reporting';
+import {PluginApi} from '../../api/plugin';
+import {Execution, LifeCycle, Timing} from '../../constants/reporting';
 
-// TODO(dmfilippov): TS-fix-any use more specific type instead if possible
-export type EventDetails = any;
+export type EventValue = string | number | {error?: Error};
 
 export interface Timer {
   reset(): this;
@@ -50,17 +52,18 @@
   reportExtension(name: string): void;
   pluginLoaded(name: string): void;
   pluginsLoaded(pluginsList?: string[]): void;
+  error(err: unknown, reporter?: string, details?: EventDetails): void;
   /**
    * Reset named timer.
    */
-  time(name: string): void;
+  time(name: Timing): void;
   /**
    * Finish named timer and report it to server.
    */
-  timeEnd(name: string, eventDetails?: EventDetails): void;
+  timeEnd(name: Timing, eventDetails?: EventDetails): void;
   /**
    * Reports just line timeEnd, but additionally reports an average given a
-   * denominator and a separate reporiting name for the average.
+   * denominator and a separate reporting name for the average.
    *
    * @param name Timing name.
    * @param averageName Average timing name.
@@ -68,12 +71,12 @@
    *     compute the average.
    */
   timeEndWithAverage(
-    name: string,
-    averageName: string,
+    name: Timing,
+    averageName: Timing,
     denominator: number
   ): void;
   /**
-   * Get a timer object to for reporing a user timing. The start time will be
+   * Get a timer object for reporting a user timing. The start time will be
    * the time that the object has been created, and the end time will be the
    * time that the "end" method is called on the object.
    */
@@ -85,13 +88,29 @@
    * @param elapsed The time elapsed of the RPC.
    */
   reportRpcTiming(anonymizedUrl: string, elapsed: number): void;
-  reportLifeCycle(eventName: string, details?: EventDetails): void;
+  reportLifeCycle(eventName: LifeCycle, details?: EventDetails): void;
+
+  /**
+   * Use this method, if you want to check/count how often a certain code path
+   * is executed. For example you can use this method to prove that certain code
+   * paths are dead: Add reportExecution(), check the logs a week later, then
+   * safely remove the code.
+   *
+   * Every execution is only reported once per session.
+   */
+  reportExecution(id: Execution, details?: EventDetails): void;
+  trackApi(
+    plugin: Pick<PluginApi, 'getPluginName'>,
+    object: string,
+    method: string
+  ): void;
   reportInteraction(eventName: string, details?: EventDetails): void;
   /**
-   * A draft interaction was started. Update the time-betweeen-draft-actions
+   * A draft interaction was started. Update the time-between-draft-actions
    * timer.
    */
   recordDraftInteraction(): void;
   reportErrorDialog(message: string): void;
   setRepoName(repoName: string): void;
+  setChangeId(changeId: NumericChangeId): void;
 }
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index f3aacdf..5fd4234 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -16,13 +16,12 @@
  */
 import {AppContext} from '../app-context';
 import {FlagsService} from '../flags/flags';
-import {
-  EventDetails,
-  EventValue,
-  ReportingService,
-  Timer,
-} from './gr-reporting';
+import {EventValue, ReportingService, Timer} from './gr-reporting';
 import {hasOwnProperty} from '../../utils/common-util';
+import {NumericChangeId} from '../../types/common';
+import {EventDetails} from '../../api/reporting';
+import {PluginApi} from '../../api/plugin';
+import {Execution, LifeCycle, Timing} from '../../constants/reporting';
 
 // Latency reporting constants.
 
@@ -32,9 +31,6 @@
     UI_LATENCY: 'UI Latency',
     RPC: 'RPC Timing',
   },
-  EVENT: {
-    APP_STARTED: 'App Started',
-  },
 };
 
 const LIFECYCLE = {
@@ -44,6 +40,7 @@
     EXTENSION_DETECTED: 'Extension detected',
     PLUGINS_INSTALLED: 'Plugins installed',
     VISIBILITY: 'Visibility',
+    EXECUTION: 'Execution',
   },
 };
 
@@ -73,39 +70,19 @@
   },
 };
 
-const TIMER = {
-  CHANGE_DISPLAYED: 'ChangeDisplayed',
-  CHANGE_LOAD_FULL: 'ChangeFullyLoaded',
-  DASHBOARD_DISPLAYED: 'DashboardDisplayed',
-  DIFF_VIEW_CONTENT_DISPLAYED: 'DiffViewOnlyContent',
-  DIFF_VIEW_DISPLAYED: 'DiffViewDisplayed',
-  DIFF_VIEW_LOAD_FULL: 'DiffViewFullyLoaded',
-  FILE_LIST_DISPLAYED: 'FileListDisplayed',
-  PLUGINS_LOADED: 'PluginsLoaded',
-  STARTUP_CHANGE_DISPLAYED: 'StartupChangeDisplayed',
-  STARTUP_CHANGE_LOAD_FULL: 'StartupChangeFullyLoaded',
-  STARTUP_DASHBOARD_DISPLAYED: 'StartupDashboardDisplayed',
-  STARTUP_DIFF_VIEW_CONTENT_DISPLAYED: 'StartupDiffViewOnlyContent',
-  STARTUP_DIFF_VIEW_DISPLAYED: 'StartupDiffViewDisplayed',
-  STARTUP_DIFF_VIEW_LOAD_FULL: 'StartupDiffViewFullyLoaded',
-  STARTUP_FILE_LIST_DISPLAYED: 'StartupFileListDisplayed',
-  WEB_COMPONENTS_READY: 'WebComponentsReady',
-  METRICS_PLUGIN_LOADED: 'MetricsPluginLoaded',
-};
-
-const STARTUP_TIMERS = {
-  [TIMER.PLUGINS_LOADED]: 0,
-  [TIMER.METRICS_PLUGIN_LOADED]: 0,
-  [TIMER.STARTUP_CHANGE_DISPLAYED]: 0,
-  [TIMER.STARTUP_CHANGE_LOAD_FULL]: 0,
-  [TIMER.STARTUP_DASHBOARD_DISPLAYED]: 0,
-  [TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED]: 0,
-  [TIMER.STARTUP_DIFF_VIEW_DISPLAYED]: 0,
-  [TIMER.STARTUP_DIFF_VIEW_LOAD_FULL]: 0,
-  [TIMER.STARTUP_FILE_LIST_DISPLAYED]: 0,
-  [TIMING.EVENT.APP_STARTED]: 0,
+const STARTUP_TIMERS: {[name: string]: number} = {
+  [Timing.PLUGINS_LOADED]: 0,
+  [Timing.METRICS_PLUGIN_LOADED]: 0,
+  [Timing.STARTUP_CHANGE_DISPLAYED]: 0,
+  [Timing.STARTUP_CHANGE_LOAD_FULL]: 0,
+  [Timing.STARTUP_DASHBOARD_DISPLAYED]: 0,
+  [Timing.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED]: 0,
+  [Timing.STARTUP_DIFF_VIEW_DISPLAYED]: 0,
+  [Timing.STARTUP_DIFF_VIEW_LOAD_FULL]: 0,
+  [Timing.STARTUP_FILE_LIST_DISPLAYED]: 0,
+  [Timing.APP_STARTED]: 0,
   // WebComponentsReady timer is triggered from gr-router.
-  [TIMER.WEB_COMPONENTS_READY]: 0,
+  [Timing.WEB_COMPONENTS_READY]: 0,
 };
 
 const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
@@ -114,7 +91,22 @@
 
 export function initErrorReporter(appContext: AppContext) {
   const reportingService = appContext.reportingService;
-  // TODO(dmfilippo): TS-fix-any oldOnError - define correct type
+
+  const normalizeError = (err: Error | unknown) => {
+    if (err instanceof Error) {
+      return err;
+    }
+    let msg = '';
+    if (typeof err === 'string') {
+      msg += err;
+    } else {
+      msg += JSON.stringify(err);
+    }
+    const error = new Error(msg);
+    error.stack = 'unknown';
+    return error;
+  };
+  // TODO(dmfilippov): TS-fix-any oldOnError - define correct type
   const onError = function (
     oldOnError: Function,
     msg: Event | string,
@@ -127,32 +119,19 @@
       oldOnError(msg, url, line, column, error);
     }
     if (error) {
-      line = line || error.lineNumber;
-      column = column || error.columnNumber;
-      let shortenedErrorStack = msg;
-      if (error.stack) {
-        const errorStackLines = error.stack.split('\n');
-        shortenedErrorStack = errorStackLines
-          .slice(0, Math.min(3, errorStackLines.length))
-          .join('\n');
-      }
-      msg = shortenedErrorStack || error.toString();
+      line = line ?? error.lineNumber;
+      column = column ?? error.columnNumber;
     }
-    const payload = {
-      url,
+    reportingService.error(normalizeError(error), 'onError', {
       line,
       column,
-      error,
-    };
-    reportingService.reporter(
-      ERROR.TYPE,
-      ERROR.CATEGORY.EXCEPTION,
-      `${msg}`,
-      payload
-    );
+      url,
+      msg,
+    });
     return true;
   };
   // TODO(dmfilippov): TS-fix-any unclear what is context
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   const catchErrors = function (opt_context?: any) {
     const context = opt_context || window;
     const oldOnError = context.onerror;
@@ -162,22 +141,11 @@
       lineno?: number,
       colno?: number,
       error?: Error
-    ) => {
-      return onError(oldOnError, event, source, lineno, colno, error);
-    };
+    ) => onError(oldOnError, event, source, lineno, colno, error);
     context.addEventListener(
       'unhandledrejection',
       (e: PromiseRejectionEvent) => {
-        const msg = e.reason.message;
-        const payload = {
-          error: e.reason,
-        };
-        reportingService.reporter(
-          ERROR.TYPE,
-          ERROR.CATEGORY.EXCEPTION,
-          msg,
-          payload
-        );
+        reportingService.error(normalizeError(e.reason), 'unhandledrejection');
       }
     );
   };
@@ -276,6 +244,7 @@
   eventStart: number;
   eventDetails?: string;
   repoName?: string;
+  changeId?: string;
   inBackgroundTab?: boolean;
   enabledExperiments?: string;
 }
@@ -300,15 +269,23 @@
 
   private readonly _baselines = STARTUP_TIMERS;
 
-  private _reportRepoName: string | undefined;
+  private reportRepoName: string | undefined;
 
-  private _timers: {timeBetweenDraftActions: Timer | null} = {
+  private reportChangeId: NumericChangeId | undefined;
+
+  private timers: {timeBetweenDraftActions: Timer | null} = {
     timeBetweenDraftActions: null,
   };
 
-  private _pending: PendingReportInfo[] = [];
+  private pending: PendingReportInfo[] = [];
 
-  private _slowRpcList: SlowRpcCall[] = [];
+  private slowRpcList: SlowRpcCall[] = [];
+
+  /**
+   * Keeps track of which ids were already reported to have been executed.
+   * Execution ids should only be reported once per session.
+   */
+  private executionReported = new Set<string>();
 
   public readonly hiddenDurationTimer = new HiddenDurationTimer();
 
@@ -321,12 +298,12 @@
   }
 
   private get slowRpcSnapshot() {
-    return (this._slowRpcList || []).slice();
+    return (this.slowRpcList || []).slice();
   }
 
   private _arePluginsLoaded() {
     return (
-      this._baselines && !hasOwnProperty(this._baselines, TIMER.PLUGINS_LOADED)
+      this._baselines && !hasOwnProperty(this._baselines, Timing.PLUGINS_LOADED)
     );
   }
 
@@ -334,7 +311,7 @@
     return (
       this._arePluginsLoaded() ||
       (this._baselines &&
-        !hasOwnProperty(this._baselines, TIMER.METRICS_PLUGIN_LOADED))
+        !hasOwnProperty(this._baselines, Timing.METRICS_PLUGIN_LOADED))
     );
   }
 
@@ -360,20 +337,22 @@
       eventDetails
     );
     if (type === ERROR.TYPE && category === ERROR.CATEGORY.EXCEPTION) {
-      console.error((eventValue && (eventValue as any).error) || eventName);
+      console.error(
+        (typeof eventValue === 'object' && eventValue.error) || eventName
+      );
     }
 
     // We report events immediately when metrics plugin is loaded
-    if (this._isMetricsPluginLoaded() && !this._pending.length) {
+    if (this._isMetricsPluginLoaded() && !this.pending.length) {
       this._reportEvent(eventInfo, noLog);
     } else {
       // We cache until metrics plugin is loaded
-      this._pending.push([eventInfo, noLog]);
+      this.pending.push([eventInfo, noLog]);
       if (this._isMetricsPluginLoaded()) {
-        this._pending.forEach(([eventInfo, opt_noLog]) => {
+        this.pending.forEach(([eventInfo, opt_noLog]) => {
           this._reportEvent(eventInfo, opt_noLog);
         });
-        this._pending = [];
+        this.pending = [];
       }
     }
   }
@@ -415,8 +394,11 @@
       eventInfo.eventDetails = JSON.stringify(eventDetails);
     }
 
-    if (this._reportRepoName) {
-      eventInfo.repoName = this._reportRepoName;
+    if (this.reportRepoName) {
+      eventInfo.repoName = this.reportRepoName;
+    }
+    if (this.reportChangeId) {
+      eventInfo.changeId = `${this.reportChangeId}`;
     }
 
     const isInBackgroundTab = document.visibilityState === 'hidden';
@@ -437,23 +419,29 @@
    * User-perceived app start time, should be reported when the app is ready.
    */
   appStarted() {
-    this.timeEnd(TIMING.EVENT.APP_STARTED);
+    this.timeEnd(Timing.APP_STARTED);
     this._reportNavResTimes();
   }
 
   onVisibilityChange() {
     this.hiddenDurationTimer.onVisibilityChange();
-    const eventName = `Visibility changed to ${document.visibilityState}`;
-    this.reporter(
-      LIFECYCLE.TYPE,
-      LIFECYCLE.CATEGORY.VISIBILITY,
-      eventName,
-      undefined,
-      {
-        hiddenDurationMs: this.hiddenDurationTimer.hiddenDurationMs,
-      },
-      true
-    );
+    let eventName;
+    if (document.visibilityState === 'hidden') {
+      eventName = LifeCycle.VISIBILILITY_HIDDEN;
+    } else if (document.visibilityState === 'visible') {
+      eventName = LifeCycle.VISIBILILITY_VISIBLE;
+    }
+    if (eventName)
+      this.reporter(
+        LIFECYCLE.TYPE,
+        LIFECYCLE.CATEGORY.VISIBILITY,
+        eventName,
+        undefined,
+        {
+          hiddenDurationMs: this.hiddenDurationTimer.hiddenDurationMs,
+        },
+        false
+      );
   }
 
   /**
@@ -489,16 +477,17 @@
     for (const prop of Object.keys(this._baselines)) {
       delete this._baselines[prop];
     }
-    this.time(TIMER.CHANGE_DISPLAYED);
-    this.time(TIMER.CHANGE_LOAD_FULL);
-    this.time(TIMER.DASHBOARD_DISPLAYED);
-    this.time(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
-    this.time(TIMER.DIFF_VIEW_DISPLAYED);
-    this.time(TIMER.DIFF_VIEW_LOAD_FULL);
-    this.time(TIMER.FILE_LIST_DISPLAYED);
-    this._reportRepoName = undefined;
+    this.time(Timing.CHANGE_DISPLAYED);
+    this.time(Timing.CHANGE_LOAD_FULL);
+    this.time(Timing.DASHBOARD_DISPLAYED);
+    this.time(Timing.DIFF_VIEW_CONTENT_DISPLAYED);
+    this.time(Timing.DIFF_VIEW_DISPLAYED);
+    this.time(Timing.DIFF_VIEW_LOAD_FULL);
+    this.time(Timing.FILE_LIST_DISPLAYED);
+    this.reportRepoName = undefined;
+    this.reportChangeId = undefined;
     // reset slow rpc list since here start page loads which report these rpcs
-    this._slowRpcList = [];
+    this.slowRpcList = [];
     this.hiddenDurationTimer.reset();
   }
 
@@ -512,60 +501,63 @@
   }
 
   dashboardDisplayed() {
-    if (hasOwnProperty(this._baselines, TIMER.STARTUP_DASHBOARD_DISPLAYED)) {
-      this.timeEnd(TIMER.STARTUP_DASHBOARD_DISPLAYED, this._pageLoadDetails());
+    if (hasOwnProperty(this._baselines, Timing.STARTUP_DASHBOARD_DISPLAYED)) {
+      this.timeEnd(Timing.STARTUP_DASHBOARD_DISPLAYED, this._pageLoadDetails());
     } else {
-      this.timeEnd(TIMER.DASHBOARD_DISPLAYED, this._pageLoadDetails());
+      this.timeEnd(Timing.DASHBOARD_DISPLAYED, this._pageLoadDetails());
     }
   }
 
   changeDisplayed() {
-    if (hasOwnProperty(this._baselines, TIMER.STARTUP_CHANGE_DISPLAYED)) {
-      this.timeEnd(TIMER.STARTUP_CHANGE_DISPLAYED, this._pageLoadDetails());
+    if (hasOwnProperty(this._baselines, Timing.STARTUP_CHANGE_DISPLAYED)) {
+      this.timeEnd(Timing.STARTUP_CHANGE_DISPLAYED, this._pageLoadDetails());
     } else {
-      this.timeEnd(TIMER.CHANGE_DISPLAYED, this._pageLoadDetails());
+      this.timeEnd(Timing.CHANGE_DISPLAYED, this._pageLoadDetails());
     }
   }
 
   changeFullyLoaded() {
-    if (hasOwnProperty(this._baselines, TIMER.STARTUP_CHANGE_LOAD_FULL)) {
-      this.timeEnd(TIMER.STARTUP_CHANGE_LOAD_FULL);
+    if (hasOwnProperty(this._baselines, Timing.STARTUP_CHANGE_LOAD_FULL)) {
+      this.timeEnd(Timing.STARTUP_CHANGE_LOAD_FULL);
     } else {
-      this.timeEnd(TIMER.CHANGE_LOAD_FULL);
+      this.timeEnd(Timing.CHANGE_LOAD_FULL);
     }
   }
 
   diffViewDisplayed() {
-    if (hasOwnProperty(this._baselines, TIMER.STARTUP_DIFF_VIEW_DISPLAYED)) {
-      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_DISPLAYED, this._pageLoadDetails());
+    if (hasOwnProperty(this._baselines, Timing.STARTUP_DIFF_VIEW_DISPLAYED)) {
+      this.timeEnd(Timing.STARTUP_DIFF_VIEW_DISPLAYED, this._pageLoadDetails());
     } else {
-      this.timeEnd(TIMER.DIFF_VIEW_DISPLAYED, this._pageLoadDetails());
+      this.timeEnd(Timing.DIFF_VIEW_DISPLAYED, this._pageLoadDetails());
     }
   }
 
   diffViewFullyLoaded() {
-    if (hasOwnProperty(this._baselines, TIMER.STARTUP_DIFF_VIEW_LOAD_FULL)) {
-      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL);
+    if (hasOwnProperty(this._baselines, Timing.STARTUP_DIFF_VIEW_LOAD_FULL)) {
+      this.timeEnd(Timing.STARTUP_DIFF_VIEW_LOAD_FULL);
     } else {
-      this.timeEnd(TIMER.DIFF_VIEW_LOAD_FULL);
+      this.timeEnd(Timing.DIFF_VIEW_LOAD_FULL);
     }
   }
 
   diffViewContentDisplayed() {
     if (
-      hasOwnProperty(this._baselines, TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED)
+      hasOwnProperty(
+        this._baselines,
+        Timing.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED
+      )
     ) {
-      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED);
+      this.timeEnd(Timing.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED);
     } else {
-      this.timeEnd(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
+      this.timeEnd(Timing.DIFF_VIEW_CONTENT_DISPLAYED);
     }
   }
 
   fileListDisplayed() {
-    if (hasOwnProperty(this._baselines, TIMER.STARTUP_FILE_LIST_DISPLAYED)) {
-      this.timeEnd(TIMER.STARTUP_FILE_LIST_DISPLAYED);
+    if (hasOwnProperty(this._baselines, Timing.STARTUP_FILE_LIST_DISPLAYED)) {
+      this.timeEnd(Timing.STARTUP_FILE_LIST_DISPLAYED);
     } else {
-      this.timeEnd(TIMER.FILE_LIST_DISPLAYED);
+      this.timeEnd(Timing.FILE_LIST_DISPLAYED);
     }
   }
 
@@ -600,21 +592,27 @@
   }
 
   reportExtension(name: string) {
-    this.reporter(LIFECYCLE.TYPE, LIFECYCLE.CATEGORY.EXTENSION_DETECTED, name);
+    this.reporter(
+      LIFECYCLE.TYPE,
+      LIFECYCLE.CATEGORY.EXTENSION_DETECTED,
+      LifeCycle.EXTENSION_DETECTED,
+      undefined,
+      {name}
+    );
   }
 
   pluginLoaded(name: string) {
     if (name.startsWith('metrics-')) {
-      this.timeEnd(TIMER.METRICS_PLUGIN_LOADED);
+      this.timeEnd(Timing.METRICS_PLUGIN_LOADED);
     }
   }
 
   pluginsLoaded(pluginsList?: string[]) {
-    this.timeEnd(TIMER.PLUGINS_LOADED);
+    this.timeEnd(Timing.PLUGINS_LOADED);
     this.reporter(
       LIFECYCLE.TYPE,
       LIFECYCLE.CATEGORY.PLUGINS_INSTALLED,
-      LIFECYCLE.CATEGORY.PLUGINS_INSTALLED,
+      LifeCycle.PLUGINS_INSTALLED,
       undefined,
       {pluginsList: pluginsList || []},
       true
@@ -622,9 +620,9 @@
   }
 
   /**
-   * Reset named timer.
+   * Reset named Timing.
    */
-  time(name: string) {
+  time(name: Timing) {
     this._baselines[name] = now();
     window.performance.mark(`${name}-start`);
   }
@@ -632,7 +630,7 @@
   /**
    * Finish named timer and report it to server.
    */
-  timeEnd(name: string, eventDetails?: EventDetails) {
+  timeEnd(name: Timing, eventDetails?: EventDetails) {
     if (!hasOwnProperty(this._baselines, name)) {
       return;
     }
@@ -645,7 +643,7 @@
     if (baseTime !== 0) {
       window.performance.measure(name, `${name}-start`);
     } else {
-      // Microsft Edge does not handle the 2nd param correctly
+      // Microsoft Edge does not handle the 2nd param correctly
       // (if undefined).
       window.performance.measure(name);
     }
@@ -653,14 +651,14 @@
 
   /**
    * Reports just line timeEnd, but additionally reports an average given a
-   * denominator and a separate reporiting name for the average.
+   * denominator and a separate reporting name for the average.
    *
    * @param name Timing name.
    * @param averageName Average timing name.
    * @param denominator Number by which to divide the total to
    *     compute the average.
    */
-  timeEndWithAverage(name: string, averageName: string, denominator: number) {
+  timeEndWithAverage(name: Timing, averageName: Timing, denominator: number) {
     if (!hasOwnProperty(this._baselines, name)) {
       return;
     }
@@ -697,7 +695,7 @@
   }
 
   /**
-   * Get a timer object to for reporing a user timing. The start time will be
+   * Get a timer object to for reporting a user timing. The start time will be
    * the time that the object has been created, and the end time will be the
    * time that the "end" method is called on the object.
    */
@@ -759,11 +757,11 @@
       true
     );
     if (elapsed >= SLOW_RPC_THRESHOLD) {
-      this._slowRpcList.push({anonymizedUrl, elapsed});
+      this.slowRpcList.push({anonymizedUrl, elapsed});
     }
   }
 
-  reportLifeCycle(eventName: string, details: EventDetails) {
+  reportLifeCycle(eventName: LifeCycle, details: EventDetails) {
     this.reporter(
       LIFECYCLE.TYPE,
       LIFECYCLE.CATEGORY.DEFAULT,
@@ -785,18 +783,41 @@
     );
   }
 
+  reportExecution(name: Execution, details?: EventDetails) {
+    const id = `${name}${JSON.stringify(details)}`;
+    if (this.executionReported.has(id)) return;
+    this.executionReported.add(id);
+    this.reporter(
+      LIFECYCLE.TYPE,
+      LIFECYCLE.CATEGORY.EXECUTION,
+      name,
+      undefined,
+      details,
+      true // skip console log
+    );
+  }
+
+  trackApi(
+    pluginApi: Pick<PluginApi, 'getPluginName'>,
+    object: string,
+    method: string
+  ) {
+    const plugin = pluginApi?.getPluginName() ?? 'unknown';
+    this.reportExecution(Execution.PLUGIN_API, {plugin, object, method});
+  }
+
   /**
-   * A draft interaction was started. Update the time-betweeen-draft-actions
-   * timer.
+   * A draft interaction was started. Update the time-between-draft-actions
+   * Timing.
    */
   recordDraftInteraction() {
     // If there is no timer defined, then this is the first interaction.
     // Set up the timer so that it's ready to record the intervening time when
     // called again.
-    const timer = this._timers.timeBetweenDraftActions;
+    const timer = this.timers.timeBetweenDraftActions;
     if (!timer) {
       // Create a timer with a maximum length.
-      this._timers.timeBetweenDraftActions = this.getTimer(
+      this.timers.timeBetweenDraftActions = this.getTimer(
         DRAFT_ACTION_TIMER
       ).withMaximum(DRAFT_ACTION_TIMER_MAX);
       return;
@@ -806,6 +827,19 @@
     timer.end().reset();
   }
 
+  error(error: Error, errorSource?: string, details?: EventDetails) {
+    const eventDetails = details ?? {};
+    const message = `${errorSource ? errorSource + ': ' : ''}${error.message}`;
+
+    this.reporter(
+      ERROR.TYPE,
+      ERROR.CATEGORY.EXCEPTION,
+      message,
+      {error},
+      {...eventDetails, stack: error.stack}
+    );
+  }
+
   reportErrorDialog(message: string) {
     this.reporter(
       ERROR.TYPE,
@@ -816,7 +850,11 @@
   }
 
   setRepoName(repoName: string) {
-    this._reportRepoName = repoName;
+    this.reportRepoName = repoName;
+  }
+
+  setChangeId(changeId: NumericChangeId) {
+    this.reportChangeId = changeId;
   }
 }
 
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index 924ddd9..c180439 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -15,6 +15,9 @@
  * limitations under the License.
  */
 import {ReportingService, Timer} from './gr-reporting';
+import {EventDetails} from '../../api/reporting';
+import {PluginApi} from '../../api/plugin';
+import {Execution} from '../../constants/reporting';
 
 export class MockTimer implements Timer {
   end(): this {
@@ -30,6 +33,10 @@
   }
 }
 
+const log = function (msg: string) {
+  console.info(`ReportingMock.${msg}`);
+};
+
 export const grReportingMock: ReportingService = {
   appStarted: () => {},
   beforeLocationChanged: () => {},
@@ -40,21 +47,38 @@
   diffViewDisplayed: () => {},
   diffViewFullyLoaded: () => {},
   fileListDisplayed: () => {},
-  getTimer: () => {
-    return new MockTimer();
+  getTimer: () => new MockTimer(),
+  locationChanged: (page: string) => {
+    log(`locationChanged: ${page}`);
   },
-  locationChanged: () => {},
-  onVisibilityChange: () => {},
+  onVisibilityChange: () => {
+    log('onVisibilityChange');
+  },
   pluginLoaded: () => {},
   pluginsLoaded: () => {},
   recordDraftInteraction: () => {},
   reporter: () => {},
-  reportErrorDialog: () => {},
+  reportErrorDialog: (message: string) => {
+    log(`reportErrorDialog: ${message}`);
+  },
+  error: () => {
+    log('error');
+  },
+  reportExecution: (id: Execution, details?: EventDetails) => {
+    log(`reportExecution '${id}': ${JSON.stringify(details)}`);
+  },
+  trackApi: (pluginApi: PluginApi, object: string, method: string) => {
+    const plugin = pluginApi?.getPluginName() ?? 'unknown';
+    log(`trackApi '${plugin}', ${object}, ${method}`);
+  },
   reportExtension: () => {},
-  reportInteraction: () => {},
+  reportInteraction: (eventName: string, details?: EventDetails) => {
+    log(`reportInteraction '${eventName}': ${JSON.stringify(details)}`);
+  },
   reportLifeCycle: () => {},
   reportRpcTiming: () => {},
   setRepoName: () => {},
+  setChangeId: () => {},
   time: () => {},
   timeEnd: () => {},
   timeEndWithAverage: () => {},
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
index 08e4a55..9b71908 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
@@ -322,7 +322,8 @@
   test('reportExtension', () => {
     service.reportExtension('foo');
     assert.isTrue(service.reporter.calledWithExactly(
-        'lifecycle', 'Extension detected', 'foo'
+        'lifecycle', 'Extension detected', 'Extension detected', undefined,
+        {name: 'foo'}
     ));
   });
 
@@ -340,6 +341,16 @@
     ));
   });
 
+  test('trackApi reports same event only once', () => {
+    sinon.spy(service, '_reportEvent');
+    const pluginApi = {getPluginName: () => 'test'};
+    service.trackApi(pluginApi, 'object', 'method');
+    service.trackApi(pluginApi, 'object', 'method');
+    assert.isTrue(service.reporter.calledOnce);
+    service.trackApi(pluginApi, 'object', 'method2');
+    assert.isTrue(service.reporter.calledTwice);
+  });
+
   test('report start time', () => {
     service.reporter.restore();
     sinon.stub(window.performance, 'now').returns(42);
@@ -463,23 +474,14 @@
       const error = new Error('bar');
       error.stack = undefined;
       emulateThrow('bar', 'http://url', 4, 2, error);
-      assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
-      const payload = reporter.lastCall.args[3];
-      assert.deepEqual(payload, {
-        url: 'http://url',
-        line: 4,
-        column: 2,
-        error,
-      });
+      assert.isTrue(reporter.calledWith('error', 'exception', 'onError: bar'));
     });
 
-    test('is reported with 3 lines of stack', () => {
+    test('is reported with stack', () => {
       const error = new Error('bar');
       emulateThrow('bar', 'http://url', 4, 2, error);
-      const expectedStack = error.stack.split('\n').slice(0, 3)
-          .join('\n');
-      assert.isTrue(reporter.calledWith('error', 'exception',
-          expectedStack));
+      const eventDetails = reporter.lastCall.args[4];
+      assert.equal(error.stack, eventDetails.stack);
     });
 
     test('prevent default event handler', () => {
@@ -487,12 +489,10 @@
     });
 
     test('unhandled rejection', () => {
-      fakeWindow.handlers['unhandledrejection']({
-        reason: {
-          message: 'bar',
-        },
-      });
-      assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
+      const newError = new Error('bar');
+      fakeWindow.handlers['unhandledrejection']({reason: newError});
+      assert.isTrue(reporter.calledWith('error', 'exception',
+          'unhandledrejection: bar'));
     });
   });
 });
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
similarity index 83%
rename from polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
rename to polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 6d65d4e..581f10b 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -15,96 +15,100 @@
  * limitations under the License.
  */
 
+import {HttpMethod} from '../../constants/constants';
 import {
+  AccountCapabilityInfo,
   AccountDetailInfo,
   AccountExternalIdInfo,
+  AccountId,
   AccountInfo,
-  NumericChangeId,
-  ServerInfo,
-  ProjectInfo,
-  AccountCapabilityInfo,
-  SuggestedReviewerInfo,
-  GroupNameToGroupInfoMap,
-  ParsedJSON,
-  PatchSetNum,
-  RequestPayload,
-  PreferencesInput,
-  DiffPreferencesInfo,
-  EditPreferencesInfo,
-  DiffPreferenceInput,
-  SshKeyInfo,
-  RepoName,
-  BranchName,
-  BranchInput,
-  TagInput,
-  GpgKeysInput,
-  GpgKeyId,
-  GpgKeyInfo,
-  PreferencesInfo,
-  EmailInfo,
-  ProjectAccessInfo,
-  CapabilityInfoMap,
-  ProjectAccessInput,
-  ChangeInfo,
-  ProjectInfoWithName,
-  GroupId,
-  GroupInfo,
-  GroupOptionsInput,
+  ActionNameToActionInfoMap,
+  Base64FileContent,
+  BasePatchSetNum,
+  BlameInfo,
   BranchInfo,
-  ConfigInfo,
-  ReviewInput,
-  EditInfo,
+  BranchInput,
+  BranchName,
+  CapabilityInfoMap,
   ChangeId,
-  DashboardInfo,
-  ProjectAccessInfoMap,
-  IncludedInInfo,
-  RobotCommentInfo,
+  ChangeInfo,
+  ChangeMessageId,
   CommentInfo,
-  PathToCommentsInfoMap,
-  PathToRobotCommentsInfoMap,
   CommentInput,
-  GroupInput,
-  PluginInfo,
-  DocResult,
+  CommitInfo,
+  ConfigInfo,
+  ConfigInput,
   ContributorAgreementInfo,
   ContributorAgreementInput,
-  Password,
-  ProjectWatchInfo,
-  NameToProjectInfoMap,
-  ProjectInput,
-  AccountId,
-  ChangeMessageId,
-  GroupAuditEventInfo,
-  EncodedGroupId,
-  Base64FileContent,
-  UrlEncodedCommentId,
-  TagInfo,
-  GitRef,
-  ConfigInput,
-  RelatedChangesInfo,
-  SubmittedTogetherInfo,
-  EmailAddress,
-  FixId,
-  FilePathToDiffInfoMap,
-  DiffInfo,
-  BlameInfo,
-  PatchRange,
-  ImagesForDiff,
-  ActionNameToActionInfoMap,
-  RevisionId,
-  GroupName,
   DashboardId,
-  HashtagsInput,
-  Hashtag,
+  DashboardInfo,
+  DiffPreferenceInput,
+  DocResult,
+  EditInfo,
+  EditPreferencesInfo,
+  EmailAddress,
+  EmailInfo,
+  EncodedGroupId,
   FileNameToFileInfoMap,
-  TopMenuEntryInfo,
+  FilePathToDiffInfoMap,
+  FixId,
+  GitRef,
+  GpgKeyId,
+  GpgKeyInfo,
+  GpgKeysInput,
+  GroupAuditEventInfo,
+  GroupId,
+  GroupInfo,
+  GroupInput,
+  GroupName,
+  GroupNameToGroupInfoMap,
+  GroupOptionsInput,
+  Hashtag,
+  HashtagsInput,
+  ImagesForDiff,
+  IncludedInInfo,
   MergeableInfo,
-  CommitInfo,
-} from '../../../types/common';
-import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {HttpMethod, IgnoreWhitespaceType} from '../../../constants/constants';
+  NameToProjectInfoMap,
+  NumericChangeId,
+  ParsedJSON,
+  Password,
+  PatchRange,
+  PatchSetNum,
+  PathToCommentsInfoMap,
+  PathToRobotCommentsInfoMap,
+  PluginInfo,
+  PreferencesInfo,
+  PreferencesInput,
+  ProjectAccessInfo,
+  ProjectAccessInfoMap,
+  ProjectAccessInput,
+  ProjectInfo,
+  ProjectInfoWithName,
+  ProjectInput,
+  ProjectWatchInfo,
+  RelatedChangesInfo,
+  RepoName,
+  RequestPayload,
+  ReviewInput,
+  RevisionId,
+  RobotCommentInfo,
+  ServerInfo,
+  SshKeyInfo,
+  SubmittedTogetherInfo,
+  SuggestedReviewerInfo,
+  TagInfo,
+  TagInput,
+  TopMenuEntryInfo,
+  UrlEncodedCommentId,
+} from '../../types/common';
+import {
+  DiffInfo,
+  DiffPreferencesInfo,
+  IgnoreWhitespaceType,
+} from '../../types/diff';
+import {ParsedChangeInfo} from '../../types/types';
+import {ErrorCallback} from '../../api/rest';
 
-export type ErrorCallback = (response?: Response | null, err?: Error) => void;
 export type CancelConditionCallback = () => boolean;
 
 // TODO(TS): remove when GrReplyDialog converted to typescript
@@ -115,21 +119,6 @@
   setPluginMessage(message: string): void;
 }
 
-// Copied from gr-change-actions.js
-export enum ActionType {
-  CHANGE = 'change',
-  REVISION = 'revision',
-}
-
-// Copied from gr-change-actions.js
-export enum ActionPriority {
-  CHANGE = 2,
-  DEFAULT = 0,
-  PRIMARY = 3,
-  REVIEW = -3,
-  REVISION = 1,
-}
-
 export interface GetDiffCommentsOutput {
   baseComments: CommentInfo[];
   comments: CommentInfo[];
@@ -141,9 +130,6 @@
 }
 
 export interface RestApiService {
-  // TODO(TS): unclear what is a second parameter. Looks like it is a mistake
-  // and it must be removed
-  dispatchEvent(event: Event, detail?: unknown): boolean;
   getConfig(noCache?: boolean): Promise<ServerInfo | undefined>;
   getLoggedIn(): Promise<boolean>;
   getPreferences(): Promise<PreferencesInfo | undefined>;
@@ -182,24 +168,20 @@
 
   getChangeSuggestedReviewers(
     changeNum: NumericChangeId,
-    input: string,
-    errFn?: ErrorCallback
+    input: string
   ): Promise<SuggestedReviewerInfo[] | undefined>;
   getChangeSuggestedCCs(
     changeNum: NumericChangeId,
-    input: string,
-    errFn?: ErrorCallback
+    input: string
   ): Promise<SuggestedReviewerInfo[] | undefined>;
   getSuggestedAccounts(
     input: string,
-    n?: number,
-    errFn?: ErrorCallback
+    n?: number
   ): Promise<AccountInfo[] | undefined>;
   getSuggestedGroups(
     input: string,
     project?: RepoName,
-    n?: number,
-    errFn?: ErrorCallback
+    n?: number
   ): Promise<GroupNameToGroupInfoMap | undefined>;
   executeChangeAction(
     changeNum: NumericChangeId,
@@ -233,30 +215,14 @@
   getDiffPreferences(): Promise<DiffPreferencesInfo | undefined>;
 
   saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response>;
-  saveDiffPreferences(
-    prefs: DiffPreferenceInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-  saveDiffPreferences(
-    prefs: DiffPreferenceInput,
-    errFn?: ErrorCallback
-  ): Promise<Response>;
 
   getEditPreferences(): Promise<EditPreferencesInfo | undefined>;
 
   saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response>;
-  saveEditPreferences(
-    prefs: EditPreferencesInfo,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-  saveEditPreferences(
-    prefs: EditPreferencesInfo,
-    errFn?: ErrorCallback
-  ): Promise<Response>;
 
   getAccountEmails(): Promise<EmailInfo[] | undefined>;
   deleteAccountEmail(email: string): Promise<Response>;
-  setPreferredAccountEmail(email: string, errFn?: ErrorCallback): Promise<void>;
+  setPreferredAccountEmail(email: string): Promise<void>;
 
   getAccountSSHKeys(): Promise<SshKeyInfo[] | undefined>;
   deleteAccountSSHKey(key: string): void;
@@ -268,25 +234,11 @@
     revision: BranchInput
   ): Promise<Response>;
 
-  createRepoBranch(
-    name: RepoName,
-    branch: BranchName,
-    revision: BranchInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
   createRepoTag(
     name: RepoName,
     tag: string,
     revision: TagInput
   ): Promise<Response>;
-
-  createRepoTag(
-    name: RepoName,
-    tag: string,
-    revision: TagInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
   addAccountGPGKey(key: GpgKeysInput): Promise<Record<string, GpgKeyInfo>>;
   deleteAccountGPGKey(id: GpgKeyId): Promise<Response>;
   getAccountGPGKeys(): Promise<Record<string, GpgKeyInfo>>;
@@ -326,11 +278,6 @@
   ): Promise<ProjectAccessInfo | undefined>;
 
   createRepo(config: ProjectInput & {name: RepoName}): Promise<Response>;
-  createRepo(
-    config: ProjectInput & {name: RepoName},
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-  createRepo(config: ProjectInput, errFn?: ErrorCallback): Promise<Response>;
 
   getRepo(
     repo: RepoName,
@@ -453,11 +400,19 @@
   ): Promise<Response>;
 
   getDiffChangeDetail(
-    changeNum: NumericChangeId,
-    errFn?: ErrorCallback,
-    cancelCondition?: CancelConditionCallback
+    changeNum: NumericChangeId
   ): Promise<ChangeInfo | undefined | null>;
 
+  getPortedComments(
+    changeNum: NumericChangeId,
+    revision: RevisionId
+  ): Promise<PathToCommentsInfoMap | undefined>;
+
+  getPortedDrafts(
+    changeNum: NumericChangeId,
+    revision: RevisionId
+  ): Promise<PathToCommentsInfoMap | undefined>;
+
   getDiffComments(
     changeNum: NumericChangeId
   ): Promise<PathToCommentsInfoMap | undefined>;
@@ -469,7 +424,7 @@
   ): Promise<GetDiffCommentsOutput>;
   getDiffComments(
     changeNum: NumericChangeId,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ):
@@ -487,7 +442,7 @@
   ): Promise<GetDiffRobotCommentsOutput>;
   getDiffRobotComments(
     changeNum: NumericChangeId,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ):
@@ -505,7 +460,7 @@
   ): Promise<GetDiffCommentsOutput>;
   getDiffDrafts(
     changeNum: NumericChangeId,
-    basePatchNum?: PatchSetNum,
+    basePatchNum?: BasePatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
   ):
@@ -513,11 +468,6 @@
     | Promise<PathToCommentsInfoMap | undefined>;
 
   createGroup(config: GroupInput & {name: string}): Promise<Response>;
-  createGroup(
-    config: GroupInput & {name: string},
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-  createGroup(config: GroupInput, errFn?: ErrorCallback): Promise<Response>;
 
   getPlugins(
     filter: string,
@@ -565,33 +515,21 @@
 
   generateAccountHttpPassword(): Promise<Password>;
 
-  setAccountName(name: string, errFn?: ErrorCallback): Promise<void>;
+  setAccountName(name: string): Promise<void>;
 
-  setAccountUsername(username: string, errFn?: ErrorCallback): Promise<void>;
+  setAccountUsername(username: string): Promise<void>;
 
   getWatchedProjects(): Promise<ProjectWatchInfo[] | undefined>;
 
   saveWatchedProjects(
-    projects: ProjectWatchInfo[],
-    errFn?: ErrorCallback
+    projects: ProjectWatchInfo[]
   ): Promise<ProjectWatchInfo[]>;
 
-  deleteWatchedProjects(
-    projects: ProjectWatchInfo[]
-  ): Promise<Response | undefined>;
-  deleteWatchedProjects(
-    projects: ProjectWatchInfo[],
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-  deleteWatchedProjects(
-    projects: ProjectWatchInfo[],
-    errFn?: ErrorCallback
-  ): Promise<Response | undefined>;
+  deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise<Response>;
 
   getSuggestedProjects(
     inputVal: string,
-    n?: number,
-    errFn?: ErrorCallback
+    n?: number
   ): Promise<NameToProjectInfoMap | undefined>;
 
   invalidateGroupsCache(): void;
@@ -608,11 +546,8 @@
     user: AccountId | undefined | null,
     reason: string
   ): Promise<Response>;
-  setAccountDisplayName(
-    displayName: string,
-    errFn?: ErrorCallback
-  ): Promise<void>;
-  setAccountStatus(status: string, errFn?: ErrorCallback): Promise<void>;
+  setAccountDisplayName(displayName: string): Promise<void>;
+  setAccountStatus(status: string): Promise<void>;
   getAvatarChangeUrl(): Promise<string | undefined>;
   setDescription(
     changeNum: NumericChangeId,
@@ -652,10 +587,7 @@
     errFn?: ErrorCallback
   ): Promise<GroupAuditEventInfo[] | undefined>;
 
-  getGroupMembers(
-    groupName: GroupId | GroupName,
-    errFn?: ErrorCallback
-  ): Promise<AccountInfo[] | undefined>;
+  getGroupMembers(groupName: GroupId | GroupName): Promise<AccountInfo[]>;
 
   getIncludedGroup(
     groupName: GroupId | GroupName
@@ -682,10 +614,7 @@
     includedGroup: GroupId
   ): Promise<Response>;
 
-  runRepoGC(
-    repo: RepoName,
-    errFn?: ErrorCallback
-  ): Promise<Response | undefined>;
+  runRepoGC(repo: RepoName): Promise<Response>;
   getFileContent(
     changeNum: NumericChangeId,
     path: string,
@@ -733,6 +662,7 @@
     topic: string,
     changeNum: NumericChangeId
   ): Promise<ChangeInfo[] | undefined>;
+  getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined>;
 
   hasPendingDiffDrafts(): number;
   awaitPendingDiffDrafts(): Promise<void>;
@@ -782,11 +712,6 @@
 
   addAccountEmail(email: string): Promise<Response>;
 
-  addAccountEmail(
-    email: string,
-    errFn?: ErrorCallback
-  ): Promise<Response | undefined>;
-
   saveChangeReviewed(
     changeNum: NumericChangeId,
     reviewed: boolean
@@ -817,10 +742,7 @@
     hashtag: HashtagsInput
   ): Promise<Hashtag[]>;
 
-  setChangeTopic(
-    changeNum: NumericChangeId,
-    topic: string | null
-  ): Promise<string>;
+  setChangeTopic(changeNum: NumericChangeId, topic?: string): Promise<string>;
 
   getChangeFiles(
     changeNum: NumericChangeId,
@@ -844,15 +766,7 @@
     reviewed: boolean
   ): Promise<Response>;
 
-  saveFileReviewed(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    path: string,
-    reviewed: boolean,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined>;
+  getTopMenus(): Promise<TopMenuEntryInfo[] | undefined>;
 
   setInProjectLookup(changeNum: NumericChangeId, project: RepoName): void;
   getMergeable(changeNum: NumericChangeId): Promise<MergeableInfo | undefined>;
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
new file mode 100644
index 0000000..201cb3f
--- /dev/null
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -0,0 +1,76 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {NumericChangeId, PatchSetNum} from '../../types/common';
+import {BehaviorSubject, Observable} from 'rxjs';
+import {distinctUntilChanged, map} from 'rxjs/operators';
+
+export enum GerritView {
+  ADMIN = 'admin',
+  AGREEMENTS = 'agreements',
+  CHANGE = 'change',
+  DASHBOARD = 'dashboard',
+  DIFF = 'diff',
+  DOCUMENTATION_SEARCH = 'documentation-search',
+  EDIT = 'edit',
+  GROUP = 'group',
+  PLUGIN_SCREEN = 'plugin-screen',
+  REPO = 'repo',
+  ROOT = 'root',
+  SEARCH = 'search',
+  SETTINGS = 'settings',
+}
+
+export interface RouterState {
+  view?: GerritView;
+  changeNum?: NumericChangeId;
+  patchNum?: PatchSetNum;
+}
+
+// TODO: Figure out how to best enforce immutability of all states. Use Immer?
+// Use DeepReadOnly?
+const initialState: RouterState = {};
+
+const privateState$ = new BehaviorSubject<RouterState>(initialState);
+
+// Re-exporting as Observable so that you can only subscribe, but not emit.
+export const routerState$: Observable<RouterState> = privateState$;
+
+// Must only be used by the router service or whatever is in control of this
+// model.
+export function updateState(
+  view?: GerritView,
+  changeNum?: NumericChangeId,
+  patchNum?: PatchSetNum
+) {
+  privateState$.next({
+    ...privateState$.getValue(),
+    view,
+    changeNum,
+    patchNum,
+  });
+}
+
+export const routerChangeNum$ = routerState$.pipe(
+  map(state => state.changeNum),
+  distinctUntilChanged()
+);
+
+export const routerPatchNum$ = routerState$.pipe(
+  map(state => state.patchNum),
+  distinctUntilChanged()
+);
diff --git a/polygerrit-ui/app/services/storage/gr-storage.ts b/polygerrit-ui/app/services/storage/gr-storage.ts
new file mode 100644
index 0000000..08a3387
--- /dev/null
+++ b/polygerrit-ui/app/services/storage/gr-storage.ts
@@ -0,0 +1,49 @@
+/**
+ * @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 {CommentRange, PatchSetNum} from '../../types/common';
+
+export interface StorageLocation {
+  changeNum: number;
+  patchNum: PatchSetNum | '@change';
+  path?: string;
+  line?: number;
+  range?: CommentRange;
+}
+
+export interface StorageObject {
+  message?: string;
+  updated: number;
+}
+
+export interface StorageService {
+  getDraftComment(location: StorageLocation): StorageObject | null;
+
+  setDraftComment(location: StorageLocation, message: string): void;
+
+  eraseDraftComment(location: StorageLocation): void;
+
+  getEditableContentItem(key: string): StorageObject | null;
+
+  setEditableContentItem(key: string, message: string): void;
+
+  getRespectfulTipVisibility(): StorageObject | null;
+
+  setRespectfulTipVisibility(delayDays?: number): void;
+
+  eraseEditableContentItem(key: string): void;
+}
diff --git a/polygerrit-ui/app/services/storage/gr-storage_impl.ts b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
new file mode 100644
index 0000000..0c0d151
--- /dev/null
+++ b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
@@ -0,0 +1,151 @@
+/**
+ * @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 {StorageLocation, StorageObject, StorageService} from './gr-storage';
+
+export const DURATION_DAY = 24 * 60 * 60 * 1000;
+
+// Clean up old entries no more frequently than one day.
+const CLEANUP_THROTTLE_INTERVAL = DURATION_DAY;
+
+const CLEANUP_PREFIXES_MAX_AGE_MAP = new Map<string, number>();
+CLEANUP_PREFIXES_MAX_AGE_MAP.set('respectfultip', 14 * DURATION_DAY);
+CLEANUP_PREFIXES_MAX_AGE_MAP.set('draft', DURATION_DAY);
+CLEANUP_PREFIXES_MAX_AGE_MAP.set('editablecontent', DURATION_DAY);
+
+export class GrStorageService implements StorageService {
+  private lastCleanup = 0;
+
+  private readonly storage = window.localStorage;
+
+  private exceededQuota = false;
+
+  getDraftComment(location: StorageLocation): StorageObject | null {
+    this.cleanupItems();
+    return this.getObject(this.getDraftKey(location));
+  }
+
+  setDraftComment(location: StorageLocation, message: string) {
+    const key = this.getDraftKey(location);
+    this.setObject(key, {message, updated: Date.now()});
+  }
+
+  eraseDraftComment(location: StorageLocation) {
+    const key = this.getDraftKey(location);
+    this.storage.removeItem(key);
+  }
+
+  getEditableContentItem(key: string): StorageObject | null {
+    this.cleanupItems();
+    return this.getObject(this.getEditableContentKey(key));
+  }
+
+  setEditableContentItem(key: string, message: string) {
+    this.setObject(this.getEditableContentKey(key), {
+      message,
+      updated: Date.now(),
+    });
+  }
+
+  getRespectfulTipVisibility(): StorageObject | null {
+    this.cleanupItems();
+    return this.getObject('respectfultip:visibility');
+  }
+
+  setRespectfulTipVisibility(delayDays = 0) {
+    this.cleanupItems();
+    this.setObject('respectfultip:visibility', {
+      updated: Date.now() + delayDays * DURATION_DAY,
+    });
+  }
+
+  eraseEditableContentItem(key: string) {
+    this.storage.removeItem(this.getEditableContentKey(key));
+  }
+
+  private getDraftKey(location: StorageLocation): string {
+    const range = location.range
+      ? `${location.range.start_line}-${location.range.start_character}` +
+        `-${location.range.end_character}-${location.range.end_line}`
+      : null;
+    let key = [
+      'draft',
+      location.changeNum,
+      location.patchNum,
+      location.path,
+      location.line || '',
+    ].join(':');
+    if (range) {
+      key = key + ':' + range;
+    }
+    return key;
+  }
+
+  private getEditableContentKey(key: string): string {
+    return `editablecontent:${key}`;
+  }
+
+  private cleanupItems() {
+    // Throttle cleanup to the throttle interval.
+    if (
+      this.lastCleanup &&
+      Date.now() - this.lastCleanup < CLEANUP_THROTTLE_INTERVAL
+    ) {
+      return;
+    }
+    this.lastCleanup = Date.now();
+
+    Object.keys(this.storage).forEach(key => {
+      const entries = CLEANUP_PREFIXES_MAX_AGE_MAP.entries();
+      for (const [prefix, expiration] of entries) {
+        if (key.startsWith(prefix)) {
+          const item = this.getObject(key);
+          if (!item || Date.now() - item.updated > expiration) {
+            this.storage.removeItem(key);
+          }
+        }
+      }
+    });
+  }
+
+  private getObject(key: string): StorageObject | null {
+    const serial = this.storage.getItem(key);
+    if (!serial) {
+      return null;
+    }
+    return JSON.parse(serial) as StorageObject;
+  }
+
+  private setObject(key: string, obj: StorageObject) {
+    if (this.exceededQuota) {
+      return;
+    }
+    try {
+      this.storage.setItem(key, JSON.stringify(obj));
+    } catch (exc) {
+      // Catch for QuotaExceededError and disable writes on local storage the
+      // first time that it occurs.
+      if (exc.code === 22) {
+        this.exceededQuota = true;
+        console.warn('Local storage quota exceeded: disabling');
+        return;
+      } else {
+        throw exc;
+      }
+    }
+  }
+}
diff --git a/polygerrit-ui/app/services/storage/gr-storage_mock.ts b/polygerrit-ui/app/services/storage/gr-storage_mock.ts
new file mode 100644
index 0000000..399ffe4
--- /dev/null
+++ b/polygerrit-ui/app/services/storage/gr-storage_mock.ts
@@ -0,0 +1,86 @@
+/**
+ * @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 {StorageLocation, StorageObject, StorageService} from './gr-storage';
+import {DURATION_DAY} from './gr-storage_impl';
+
+const storage = new Map<string, StorageObject>();
+
+const getDraftKey = (location: StorageLocation): string => {
+  const range = location.range
+    ? `${location.range.start_line}-${location.range.start_character}` +
+      `-${location.range.end_character}-${location.range.end_line}`
+    : null;
+  let key = [
+    'draft',
+    location.changeNum,
+    location.patchNum,
+    location.path,
+    location.line || '',
+  ].join(':');
+  if (range) {
+    key = key + ':' + range;
+  }
+  return key;
+};
+
+const getEditableContentKey = (key: string): string => `editablecontent:${key}`;
+
+export function cleanUpStorage() {
+  storage.clear();
+}
+
+export const grStorageMock: StorageService = {
+  getDraftComment(location: StorageLocation): StorageObject | null {
+    return storage.get(getDraftKey(location)) ?? null;
+  },
+
+  setDraftComment(location: StorageLocation, message: string) {
+    const key = getDraftKey(location);
+    storage.set(key, {message, updated: Date.now()});
+  },
+
+  eraseDraftComment(location: StorageLocation) {
+    const key = getDraftKey(location);
+    storage.delete(key);
+  },
+
+  getEditableContentItem(key: string): StorageObject | null {
+    return storage.get(getEditableContentKey(key)) ?? null;
+  },
+
+  setEditableContentItem(key: string, message: string): void {
+    storage.set(getEditableContentKey(key), {
+      message,
+      updated: Date.now(),
+    });
+  },
+
+  getRespectfulTipVisibility(): StorageObject | null {
+    return storage.get('respectfultip:visibility') ?? null;
+  },
+
+  setRespectfulTipVisibility(delayDays = 0): void {
+    storage.set('respectfultip:visibility', {
+      updated: Date.now() + delayDays * DURATION_DAY,
+    });
+  },
+
+  eraseEditableContentItem(key: string): void {
+    storage.delete(getEditableContentKey(key));
+  },
+};
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js b/polygerrit-ui/app/services/storage/gr-storage_test.js
similarity index 66%
rename from polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js
rename to polygerrit-ui/app/services/storage/gr-storage_test.js
index 99f953f..6cbfacf 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js
+++ b/polygerrit-ui/app/services/storage/gr-storage_test.js
@@ -15,13 +15,11 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-storage.js';
-
-const basicFixture = fixtureFromElement('gr-storage');
+import '../../test/common-test-setup-karma.js';
+import {GrStorageService} from './gr-storage_impl.js';
 
 suite('gr-storage tests', () => {
-  let element;
+  let grStorage;
 
   function mockStorage(opt_quotaExceeded) {
     return {
@@ -36,9 +34,8 @@
   }
 
   setup(() => {
-    element = basicFixture.instantiate();
-
-    element._storage = mockStorage();
+    grStorage = new GrStorageService();
+    grStorage.storage = mockStorage();
   });
 
   test('storing, retrieving and erasing drafts', () => {
@@ -54,23 +51,23 @@
     };
 
     // The key is in the expected format.
-    const key = element._getDraftKey(location);
+    const key = grStorage.getDraftKey(location);
     assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
 
     // There should be no draft initially.
-    const draft = element.getDraftComment(location);
+    const draft = grStorage.getDraftComment(location);
     assert.isNotOk(draft);
 
     // Setting the draft stores it under the expected key.
-    element.setDraftComment(location, 'my comment');
-    assert.isOk(element._storage.getItem(key));
-    assert.equal(JSON.parse(element._storage.getItem(key)).message,
+    grStorage.setDraftComment(location, 'my comment');
+    assert.isOk(grStorage.storage.getItem(key));
+    assert.equal(JSON.parse(grStorage.storage.getItem(key)).message,
         'my comment');
-    assert.isOk(JSON.parse(element._storage.getItem(key)).updated);
+    assert.isOk(JSON.parse(grStorage.storage.getItem(key)).updated);
 
     // Erasing the draft removes the key.
-    element.eraseDraftComment(location);
-    assert.isNotOk(element._storage.getItem(key));
+    grStorage.eraseDraftComment(location);
+    assert.isNotOk(grStorage.storage.getItem(key));
   });
 
   test('automatically removes old drafts', () => {
@@ -85,28 +82,28 @@
       line,
     };
 
-    const key = element._getDraftKey(location);
+    const key = grStorage.getDraftKey(location);
 
     // Make sure that the call to cleanup doesn't get throttled.
-    element._lastCleanup = 0;
+    grStorage.lastCleanup = 0;
 
-    const cleanupSpy = sinon.spy(element, '_cleanupItems');
+    const cleanupSpy = sinon.spy(grStorage, 'cleanupItems');
 
     // Create a message with a timestamp that is a second behind the max age.
-    element._storage.setItem(key, JSON.stringify({
+    grStorage.storage.setItem(key, JSON.stringify({
       message: 'old message',
       updated: Date.now() - 24 * 60 * 60 * 1000 - 1000,
     }));
 
     // Getting the draft should cause it to be removed.
-    const draft = element.getDraftComment(location);
+    const draft = grStorage.getDraftComment(location);
 
     assert.isTrue(cleanupSpy.called);
     assert.isNotOk(draft);
-    assert.isNotOk(element._storage.getItem(key));
+    assert.isNotOk(grStorage.storage.getItem(key));
   });
 
-  test('_getDraftKey', () => {
+  test('getDraftKey', () => {
     const changeNum = 1234;
     const patchNum = 5;
     const path = 'my_source_file.js';
@@ -118,7 +115,7 @@
       line,
     };
     let expectedResult = 'draft:1234:5:my_source_file.js:123';
-    assert.equal(element._getDraftKey(location), expectedResult);
+    assert.equal(grStorage.getDraftKey(location), expectedResult);
     location.range = {
       start_character: 1,
       start_line: 1,
@@ -126,12 +123,12 @@
       end_line: 2,
     };
     expectedResult = 'draft:1234:5:my_source_file.js:123:1-1-1-2';
-    assert.equal(element._getDraftKey(location), expectedResult);
+    assert.equal(grStorage.getDraftKey(location), expectedResult);
   });
 
   test('exceeded quota disables storage', () => {
-    element._storage = mockStorage(true);
-    assert.isFalse(element._exceededQuota);
+    grStorage.storage = mockStorage(true);
+    assert.isFalse(grStorage.exceededQuota);
 
     const changeNum = 1234;
     const patchNum = 5;
@@ -143,37 +140,37 @@
       path,
       line,
     };
-    const key = element._getDraftKey(location);
-    element.setDraftComment(location, 'my comment');
-    assert.isTrue(element._exceededQuota);
-    assert.isNotOk(element._storage.getItem(key));
+    const key = grStorage.getDraftKey(location);
+    grStorage.setDraftComment(location, 'my comment');
+    assert.isTrue(grStorage.exceededQuota);
+    assert.isNotOk(grStorage.storage.getItem(key));
   });
 
   test('editable content items', () => {
-    const cleanupStub = sinon.stub(element, '_cleanupItems');
+    const cleanupStub = sinon.stub(grStorage, 'cleanupItems');
     const key = 'testKey';
-    const computedKey = element._getEditableContentKey(key);
+    const computedKey = grStorage.getEditableContentKey(key);
     // Key correctly computed.
     assert.equal(computedKey, 'editablecontent:testKey');
 
-    element.setEditableContentItem(key, 'my content');
+    grStorage.setEditableContentItem(key, 'my content');
 
     // Setting the draft stores it under the expected key.
-    let item = element._storage.getItem(computedKey);
+    let item = grStorage.storage.getItem(computedKey);
     assert.isOk(item);
     assert.equal(JSON.parse(item).message, 'my content');
     assert.isOk(JSON.parse(item).updated);
 
     // getEditableContentItem performs as expected.
-    item = element.getEditableContentItem(key);
+    item = grStorage.getEditableContentItem(key);
     assert.isOk(item);
     assert.equal(item.message, 'my content');
     assert.isOk(item.updated);
     assert.isTrue(cleanupStub.called);
 
     // eraseEditableContentItem performs as expected.
-    element.eraseEditableContentItem(key);
-    assert.isNotOk(element._storage.getItem(computedKey));
+    grStorage.eraseEditableContentItem(key);
+    assert.isNotOk(grStorage.storage.getItem(computedKey));
   });
 });
 
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
index 6de4e6f..d1fcdc9 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -82,7 +82,7 @@
         vertical-align: middle;
       }
       .owner {
-        --account-max-length: 120px;
+        --account-max-length: 100px;
       }
       .branch,
       .star,
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.ts b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
index 8e8b264..e9a79c1f 100644
--- a/polygerrit-ui/app/styles/gr-menu-page-styles.ts
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
@@ -28,7 +28,7 @@
       :host {
         display: block;
       }
-      main {
+      .main {
         margin: var(--spacing-xxl) auto;
         max-width: 50em;
       }
@@ -36,7 +36,7 @@
         margin-left: 14em;
         padding: var(--spacing-l) 0 var(--spacing-l) var(--spacing-xxl);
       }
-      main.table,
+      .main.table,
       .mainHeader {
         margin-top: 0;
         margin-right: 0;
@@ -52,10 +52,10 @@
         padding: var(--spacing-l);
       }
       @media only screen and (max-width: 67em) {
-        main {
+        .main {
           margin: var(--spacing-xxl) 0 var(--spacing-xxl) 15em;
         }
-        main.table {
+        .main.table {
           margin-left: 14em;
         }
       }
@@ -63,10 +63,10 @@
         .loading {
           padding: 0 var(--spacing-l);
         }
-        main {
+        .main {
           margin: var(--spacing-xxl) var(--spacing-l);
         }
-        main.table {
+        .main.table {
           margin: 0;
         }
         .mainHeader {
diff --git a/polygerrit-ui/app/styles/gr-subpage-styles.ts b/polygerrit-ui/app/styles/gr-subpage-styles.ts
index 640da66..41ee952 100644
--- a/polygerrit-ui/app/styles/gr-subpage-styles.ts
+++ b/polygerrit-ui/app/styles/gr-subpage-styles.ts
@@ -25,7 +25,7 @@
 $_documentContainer.innerHTML = `<dom-module id="gr-subpage-styles">
   <template>
     <style>
-      main {
+      .main {
         margin: var(--spacing-l);
       }
       .loading {
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.ts b/polygerrit-ui/app/styles/gr-voting-styles.ts
index d4e6d52..b50aee6 100644
--- a/polygerrit-ui/app/styles/gr-voting-styles.ts
+++ b/polygerrit-ui/app/styles/gr-voting-styles.ts
@@ -27,8 +27,16 @@
     <style>
       :host {
         --vote-chip-styles: {
-          border: 1px solid rgba(0,0,0,.12);
-          border-radius: 1em;
+          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;
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index 695ae24..6d128b3 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 
+import {css} from 'lit-element';
+
 // Mark the file as a module. Otherwise typescript assumes this is a script
 // and $_documentContainer is a global variable.
 // See: https://www.typescriptlang.org/docs/handbook/modules.html
@@ -22,189 +24,277 @@
 
 const $_documentContainer = document.createElement('template');
 
+export const sharedStyles = css`
+  /* CSS reset */
+
+  html,
+  body,
+  button,
+  div,
+  span,
+  applet,
+  object,
+  iframe,
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6,
+  p,
+  blockquote,
+  pre,
+  a,
+  abbr,
+  acronym,
+  address,
+  big,
+  cite,
+  code,
+  del,
+  dfn,
+  em,
+  img,
+  ins,
+  kbd,
+  q,
+  s,
+  samp,
+  small,
+  strike,
+  strong,
+  sub,
+  sup,
+  tt,
+  var,
+  b,
+  u,
+  i,
+  center,
+  dl,
+  dt,
+  dd,
+  ol,
+  ul,
+  li,
+  fieldset,
+  form,
+  label,
+  legend,
+  table,
+  caption,
+  tbody,
+  tfoot,
+  thead,
+  tr,
+  th,
+  td,
+  article,
+  aside,
+  canvas,
+  details,
+  embed,
+  figure,
+  figcaption,
+  footer,
+  header,
+  hgroup,
+  main,
+  menu,
+  nav,
+  output,
+  ruby,
+  section,
+  summary,
+  time,
+  mark,
+  audio,
+  video {
+    border: 0;
+    box-sizing: border-box;
+    font-size: 100%;
+    font: inherit;
+    margin: 0;
+    padding: 0;
+    vertical-align: baseline;
+  }
+  *::after,
+  *::before {
+    box-sizing: border-box;
+  }
+  input {
+    background-color: var(--background-color-primary);
+    border: 1px solid var(--border-color);
+    border-radius: var(--border-radius);
+    box-sizing: border-box;
+    color: var(--primary-text-color);
+    margin: 0;
+    padding: var(--spacing-s);
+  }
+  iron-autogrow-textarea {
+    background-color: inherit;
+    color: var(--primary-text-color);
+    border: 1px solid var(--border-color);
+    border-radius: var(--border-radius);
+    padding: 0;
+    box-sizing: border-box;
+    /* iron-autogrow-textarea has a "-webkit-appearance: textarea" :host
+        css rule, which prevents overriding the border color. Clear that. */
+    -webkit-appearance: none;
+
+    --iron-autogrow-textarea: {
+      box-sizing: border-box;
+      padding: var(--spacing-s);
+    }
+  }
+  a {
+    color: var(--link-color);
+  }
+  input,
+  textarea,
+  select,
+  button {
+    font: inherit;
+  }
+  ol,
+  ul {
+    list-style: none;
+  }
+  blockquote,
+  q {
+    quotes: none;
+  }
+  blockquote:before,
+  blockquote:after,
+  q:before,
+  q:after {
+    content: '';
+    content: none;
+  }
+  table {
+    border-collapse: collapse;
+    border-spacing: 0;
+  }
+
+  /* Fonts */
+
+  .font-normal {
+    font-size: var(--font-size-normal);
+    font-weight: var(--font-weight-normal);
+    line-height: var(--line-height-normal);
+  }
+  .font-small {
+    font-size: var(--font-size-small);
+    font-weight: var(--font-weight-normal);
+    line-height: var(--line-height-small);
+  }
+  .heading-1 {
+    font-family: var(--header-font-family);
+    font-size: var(--font-size-h1);
+    font-weight: var(--font-weight-h1);
+    line-height: var(--line-height-h1);
+  }
+  .heading-2 {
+    font-family: var(--header-font-family);
+    font-size: var(--font-size-h2);
+    font-weight: var(--font-weight-h2);
+    line-height: var(--line-height-h2);
+  }
+  .heading-3 {
+    font-family: var(--header-font-family);
+    font-size: var(--font-size-h3);
+    font-weight: var(--font-weight-h3);
+    line-height: var(--line-height-h3);
+  }
+  iron-icon {
+    color: var(--deemphasized-text-color);
+    vertical-align: top;
+    --iron-icon-height: 20px;
+    --iron-icon-width: 20px;
+  }
+
+  /* Stopgap solution until we remove hidden$ attributes. */
+
+  :host([hidden]),
+  [hidden] {
+    display: none !important;
+  }
+  .separator {
+    border-left: 1px solid var(--border-color);
+    height: 20px;
+    margin: 0 8px;
+  }
+  .separator.transparent {
+    border-color: transparent;
+  }
+  paper-toggle-button {
+    --paper-toggle-button-checked-bar-color: var(--link-color);
+    --paper-toggle-button-checked-button-color: var(--link-color);
+  }
+  paper-tabs {
+    font-size: var(--font-size-h3);
+    font-weight: var(--font-weight-h3);
+    line-height: var(--line-height-h3);
+    --paper-font-common-base: {
+      font-family: var(--header-font-family);
+      -webkit-font-smoothing: initial;
+    }
+    --paper-tab-content-focused: {
+      /* paper-tabs uses 700 here, which can look awkward */
+      font-weight: var(--font-weight-h3);
+    }
+    --paper-tab-content-unselected: {
+      /* paper-tabs uses 0.8 here, but we want to control the color directly */
+      opacity: 1;
+      color: var(--deemphasized-text-color);
+    }
+  }
+  iron-autogrow-textarea {
+    /** This is needed for firefox */
+    --iron-autogrow-textarea_-_white-space: pre-wrap;
+  }
+  strong {
+    font-weight: var(--font-weight-bold);
+  }
+
+  .assistive-tech-only {
+    user-select: none;
+    clip: rect(1px, 1px, 1px, 1px);
+    height: 1px;
+    margin: 0;
+    overflow: hidden;
+    padding: 0;
+    position: absolute;
+    white-space: nowrap;
+    width: 1px;
+    z-index: -1000;
+  }
+
+  /** BEGIN: loading spiner */
+  .loadingSpin {
+    border: 2px solid var(--disabled-button-background-color);
+    border-top: 2px solid var(--primary-button-background-color);
+    border-radius: 50%;
+    width: 10px;
+    height: 10px;
+    animation: spin 2s linear infinite;
+    margin-right: var(--spacing-s);
+  }
+  @keyframes spin {
+    0% {
+      transform: rotate(0deg);
+    }
+    100% {
+      transform: rotate(360deg);
+    }
+  }
+  /** END: loading spiner */
+`;
+
 $_documentContainer.innerHTML = `<dom-module id="shared-styles">
   <template>
     <style>
-
-      /* CSS reset */
-
-      html, body, button, div, span, applet, object, iframe, h1, h2, h3,
-      h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite,
-      code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub,
-      sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form,
-      label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article,
-      aside, canvas, details, embed, figure, figcaption, footer, header, hgroup,
-      main, menu, nav, output, ruby, section, summary, time, mark, audio, video {
-        border: 0;
-        box-sizing: border-box;
-        font-size: 100%;
-        font: inherit;
-        margin: 0;
-        padding: 0;
-        vertical-align: baseline;
-      }
-      *::after,
-      *::before {
-        box-sizing: border-box;
-      }
-      input {
-        background-color: var(--background-color-primary);
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        box-sizing: border-box;
-        color: var(--primary-text-color);
-        margin: 0;
-        padding: var(--spacing-s);
-      }
-      iron-autogrow-textarea {
-        background-color: inherit;
-        color: var(--primary-text-color);
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        padding: 0;
-        box-sizing: border-box;
-        /* iron-autogrow-textarea has a "-webkit-appearance: textarea" :host
-           css rule, which prevents overriding the border color. Clear that. */
-        -webkit-appearance: none;
-
-        --iron-autogrow-textarea: {
-          box-sizing: border-box;
-          padding: var(--spacing-s);
-        };
-      }
-      a {
-        color: var(--link-color);
-      }
-      input,
-      textarea,
-      select,
-      button {
-        font: inherit;
-      }
-      ol, ul {
-        list-style: none;
-      }
-      blockquote, q {
-        quotes: none;
-      }
-      blockquote:before, blockquote:after,
-      q:before, q:after {
-        content: '';
-        content: none;
-      }
-      table {
-        border-collapse: collapse;
-        border-spacing: 0;
-      }
-
-      /* Fonts */
-
-      .font-normal {
-        font-size: var(--font-size-normal);
-        font-weight: var(--font-weight-normal);
-        line-height: var(--line-height-normal);
-      }
-      .font-small {
-        font-size: var(--font-size-small);
-        font-weight: var(--font-weight-normal);
-        line-height: var(--line-height-small);
-      }
-      .heading-1 {
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h1);
-        font-weight: var(--font-weight-h1);
-        line-height: var(--line-height-h1);
-      }
-      .heading-2 {
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h2);
-        font-weight: var(--font-weight-h2);
-        line-height: var(--line-height-h2);
-      }
-      .heading-3 {
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-      }
-      iron-icon {
-        color: var(--deemphasized-text-color);
-        --iron-icon-height: 20px;
-        --iron-icon-width: 20px;
-      }
-
-      /* Stopgap solution until we remove hidden$ attributes. */
-
-      :host([hidden]),
-      [hidden] {
-        display: none !important;
-      }
-      .separator {
-        border-left: 1px solid var(--border-color);
-        height: 20px;
-        margin: 0 8px;
-      }
-      .separator.transparent {
-        border-color: transparent;
-      }
-      paper-toggle-button {
-        --paper-toggle-button-checked-bar-color: var(--link-color);
-        --paper-toggle-button-checked-button-color: var(--link-color);
-      }
-      paper-tabs {
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-        --paper-font-common-base: {
-          font-family: var(--header-font-family);
-          -webkit-font-smoothing: initial;
-        };
-        --paper-tab-content-focused: {
-          /* paper-tabs uses 700 here, which can look awkward */
-          font-weight: var(--font-weight-h3);
-        };
-        --paper-tab-content-unselected: {
-          /* paper-tabs uses 0.8 here, but we want to control the color directly */
-          opacity: 1;
-          color: var(--deemphasized-text-color);
-        };
-      }
-      iron-autogrow-textarea {
-        /** This is needed for firefox */
-        --iron-autogrow-textarea_-_white-space: pre-wrap;
-      }
-      strong {
-        font-weight: var(--font-weight-bold);
-      }
-
-      .assistive-tech-only {
-        user-select: none;
-        clip: rect(1px, 1px, 1px, 1px);
-        height: 1px;
-        margin: 0;
-        overflow: hidden;
-        padding: 0;
-        position: absolute;
-        white-space: nowrap;
-        width: 1px;
-        z-index: -1000;
-      }
-
-      /** BEGIN: loading spiner */
-      .loadingSpin {
-        border: 2px solid var(--disabled-button-background-color);
-        border-top: 2px solid var(--primary-button-background-color);
-        border-radius: 50%;
-        width: 10px;
-        height: 10px;
-        animation: spin 2s linear infinite;
-        margin-right: var(--spacing-s);
-      }
-      @keyframes spin {
-        0% { transform: rotate(0deg); }
-        100% { transform: rotate(360deg); }
-      }
-      /** END: loading spiner */
+    ${sharedStyles.cssText}
     </style>
   </template>
 </dom-module>`;
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 8e362ab8..83ff552 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -39,28 +39,161 @@
      * can be a breaking change that should go into the release notes.
      */
 
-    /* text colors */
-    --primary-text-color: black;
-    --link-color: #2a66d9;
-    --comment-text-color: black;
-    --deemphasized-text-color: #5F6368;
-    --default-button-text-color: #2a66d9;
-    --chip-selected-text-color: var(--default-button-text-color);
-    --error-text-color: red;
+    /* color palette */
+    --gerrit-blue-light: #1565c0;
+    --gerrit-blue-dark: #90caf9;
+    --red-900: #a50e0e;
+    --red-700: #c5221f;
+    --red-700-04: #c5221f0a;
+    --red-700-10: #c5221f1a;
+    --red-700-12: #c5221f1f;
+    --red-600: #d93025;
+    --red-300: #f28b82;
+    --red-200: #f6aea9;
+    --red-50: #fce8e6;
+    --red-tonal: #6c322f;
+    --blue-900: #174ea6;
+    --blue-800: #185abc;
+    --blue-700: #1967d2;
+    --blue-700-04: #1967d20a;
+    --blue-700-10: #1967d21a;
+    --blue-700-12: #1967d21f;
+    --blue-700-16: #1967d229;
+    --blue-700-24: #1967d23d;
+    --blue-400: #669df6;
+    --blue-200: #aecbfa;
+    --blue-200-16: #aecbfa29;
+    --blue-200-24: #aecbfa3d;
+    --blue-100: #d2e3fc;
+    --blue-50: #e8f0fe;
+    --blue-tonal: #314972;
+    --orange-900: #b06000;
+    --orange-700: #d56e0c;
+    --orange-700-04: #d56e0c0a;
+    --orange-700-10: #d56e0c1a;
+    --orange-700-12: #d56e0c1f;
+    --orange-200: #fdc69c;
+    --orange-50: #feefe3;
+    --orange-tonal: #714625;
+    --cyan-900: #007b83;
+    --cyan-700: #129eaf;
+    --cyan-200: #a1e4f2;
+    --cyan-100: #cbf0f8;
+    --cyan-50: #e4f7fb;
+    --cyan-tonal: #275e6b;
+    --green-900: #0d652d;
+    --green-700: #188038;
+    --green-700-04: #1880380a;
+    --green-700-10: #1880381a;
+    --green-700-12: #1880381f;
+    --green-400: #5bb974;
+    --green-300: #81c995;
+    --green-200: #a8dab5;
+    --green-50: #e6f4ea;
+    --green-tonal: #2c553a;
+    --gray-900: #202124;
+    --gray-800: #3c4043;
+    --gray-800-12: #3c40431f;
+    --gray-800-38: #3c404361;
+    --gray-700: #5f6368;
+    --gray-700-04: #5f63680a;
+    --gray-700-10: #5f63681a;
+    --gray-700-12: #5f63681f;
+    --gray-500: #9aa0a6;
+    --gray-300: #dadce0;
+    --gray-200: #e8eaed;
+    --gray-200-12: #e8eaed1f;
+    --gray-200-38: #e8eaed61;
+    --gray-100: #f1f3f4;
+    --gray-50: #f8f9fa;
+    --gray-tonal: #505357;
+    --purple-900: #681da8;
+    --purple-700: #8430ce;
+    --purple-500: #a142f4;
+    --purple-400: #af5cf7;
+    --purple-200: #d7aefb;
+    --purple-50: #f3e8fd;
+    --purple-tonal: #523272;
+    --pink-800: #b80672;
+    --pink-500: #f538a0;
+    --pink-50: #fde7f3;
+    --pink-tonal: #702f55;
+    --yellow-50: #fef7e0;
+    --yellow-tonal: #6a5619;
+    --brown-50: #efebe9;
+    --brown-tonal: #6d4c41;
+    --white-04: #ffffff0a;
+    --white-10: #ffffff1a;
+    --white-12: #ffffff1f;
+
+    --error-foreground: var(--red-700);
+    --error-background: var(--red-50);
+    --error-background-hover: linear-gradient(var(--red-700-04), var(--red-700-04)), var(--red-50);
+    --error-background-focus: linear-gradient(var(--red-700-12), var(--red-700-12)), var(--red-50);
+    --error-ripple: var(--red-700-10);
+
+    --warning-foreground: var(--orange-700);
+    --warning-background: var(--orange-50);
+    --warning-background-hover: linear-gradient(var(--orange-700-04), var(--orange-700-04)), var(--orange-50);
+    --warning-background-focus: linear-gradient(var(--orange-700-12), var(--orange-700-12)), var(--orange-50);
+    --warning-ripple: var(--orange-700-10);
+
+    --info-foreground: var(--blue-700);
+    --info-background: var(--blue-50);
+    --info-background-hover: linear-gradient(var(--blue-700-04), var(--blue-700-04)), var(--blue-50);
+    --info-background-focus: linear-gradient(var(--blue-700-12), var(--blue-700-12)), var(--blue-50);
+    --info-ripple: var(--blue-700-10);
+
     --primary-button-text-color: white;
-      /* Used on text color for change list that doesn't need user's attention. */
+    --primary-button-background-color: var(--gerrit-blue-light);
+    --primary-button-background-hover: var(--blue-700-16);
+    --primary-button-background-focus: var(--blue-700-24);
+
+    --selected-foreground: var(--blue-800);
+    --selected-background: var(--blue-50);
+
+    --success-foreground: var(--green-700);
+    --success-background: var(--green-50);
+    --success-background-hover: linear-gradient(var(--green-700-04), var(--green-700-04)), var(--green-50);
+    --success-background-focus: linear-gradient(var(--green-700-12), var(--green-700-12)), var(--green-50);
+    --success-ripple: var(--green-700-10);
+
+    --gray-foreground: var(--gray-700);
+    --gray-background: var(--gray-100);
+    --gray-background-hover: linear-gradient(var(--gray-700-04), var(--gray-700-04)), var(--gray-100);
+    --gray-background-focus: linear-gradient(var(--gray-700-12), var(--gray-700-12)), var(--gray-100);
+    --gray-ripple: var(--gray-700-10);
+
+    --disabled-foreground: var(--gray-800-38);
+    --disabled-background: var(--gray-800-12);
+
+    --chip-color: var(--gray-900);
+    --error-color: var(--red-900);
+    --tag-background: var(--cyan-100);
+    --label-background: var(--red-50);
+
+    /* text colors */
+    --primary-text-color: var(--gray-900);
+    --link-color: var(--gerrit-blue-light);
+    --comment-text-color: var(--gray-900);
+    --deemphasized-text-color: var(--gray-700);
+    --default-button-text-color: var(--gerrit-blue-light);
+    --chip-selected-text-color: var(--selected-foreground);
+    --error-text-color: var(--red-700);
+    /* Used on text color for change list that doesn't need user's attention. */
     --reviewed-text-color: black;
     --vote-text-color: black;
     --status-text-color: white;
     --tooltip-text-color: white;
-    --negative-red-text-color: #d93025;
-    --positive-green-text-color: #188038;
+    --negative-red-text-color: var(--red-600);
+    --positive-green-text-color: var(--green-700);
+    --indirect-ancestor-text-color: var(--green-700);
 
     /* background colors */
     /* primary background colors */
-    --background-color-primary: #ffffff;
-    --background-color-secondary: #f8f9fa;
-    --background-color-tertiary: #f1f3f4;
+    --background-color-primary: white;
+    --background-color-secondary: var(--gray-50);
+    --background-color-tertiary: var(--gray-100);
     /* directly derived from primary background colors */
     --chip-background-color: var(--background-color-tertiary);
     --default-button-background-color: var(--background-color-primary);
@@ -78,38 +211,56 @@
     /* TODO: Find a nicer way to combine the --assignee-highlight-color and the
        --selection-background-color than to just invent another unique color. */
     --assignee-highlight-selection-color: #f6f4d0;
-    --chip-selected-background-color: #e8f0fe;
+    --chip-selected-background-color: var(--blue-50);
     --edit-mode-background-color: #ebf5fb;
     --emphasis-color: #fff9c4;
     --hover-background-color: rgba(161, 194, 250, 0.2);
-    --disabled-button-background-color: #e8eaed;
-    --primary-button-background-color: #2a66d9;
+    --disabled-button-background-color: var(--disabled-background);
     --selection-background-color: rgba(161, 194, 250, 0.1);
-    --tooltip-background-color: #333;
+    --tooltip-background-color: var(--gray-900);
+
     /* comment background colors */
-    --comment-background-color: #e8eaed;
-    --robot-comment-background-color: #e8f0fe;
+    --comment-background-color: var(--gray-200);
+    --robot-comment-background-color: var(--blue-50);
     --unresolved-comment-background-color: #fef7e0;
+
     /* vote background colors */
-    --vote-color-approved: #9fcc6b;
-    --vote-color-disliked: #f7c4cb;
-    --vote-color-neutral: #ebf5fb;
-    --vote-color-recommended: #c9dfaf;
-    --vote-color-rejected: #f7a1ad;
+    --vote-color-approved: var(--green-300);
+    --vote-color-disliked: var(--red-50);
+    --vote-outline-disliked: var(--red-700);
+    --vote-color-neutral: var(--gray-300);
+    --vote-color-recommended: var(--green-50);
+    --vote-outline-recommended: var(--green-700);
+    --vote-color-rejected: var(--red-300);
+
+    --outline-color-focus: var(--gray-900);
 
     /* misc colors */
-    --border-color: #e8e8e8;
-    --comment-separator-color: #dadce0;
+    --border-color: var(--gray-300);
+    --comment-separator-color: var(--gray-300);
+
+    /* checks tag colors */
+    --tag-gray: var(--gray-200);
+    --tag-yellow: var(--yellow-50);
+    --tag-pink: var(--pink-50);
+    --tag-purple: var(--purple-50);
+    --tag-cyan: var(--cyan-50);
+    --tag-brown: var(--brown-50);
 
     /* status colors */
-    --status-merged: #188038;
-    --status-abandoned: #5f6368;
+    --status-merged: var(--green-700);
+    --status-abandoned: var(--gray-700);
     --status-wip: #795548;
-    --status-private: #a142f4;
-    --status-conflict: #d93025;
-    --status-active: #1976d2;
-    --status-ready: #b80672;
-    --status-custom: #681da8;
+    --status-private: var(--purple-500);
+    --status-conflict: var(--red-600);
+    --status-active: var(--blue-700);
+    --status-ready: var(--pink-800);
+    --status-custom: var(--purple-900);
+
+    /* file status colors */
+    --file-status-added: var(--green-300);
+    --file-status-changed: var(--red-200);
+    --file-status-unchanged: var(--grey-300);
 
     /* fonts */
     --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
@@ -122,11 +273,10 @@
     --font-size-h3: 1.143rem;   /* 16px */
     --font-size-h2: 1.429rem;   /* 20px */
     --font-size-h1: 1.714rem;   /* 24px */
-    --line-height-code: 1.143rem;   /* 16px */
     --line-height-mono: 1.286rem;   /* 18px */
     --line-height-small: 1.143rem;  /* 16px */
     --line-height-normal: 1.429rem; /* 20px */
-    --line-height-h3: 1.714rem;     /* 24px */
+    --line-height-h3: 1.715rem;     /* 24px */
     --line-height-h2: 2rem;         /* 28px */
     --line-height-h1: 2.286rem;     /* 32px */
     --font-weight-normal: 400; /* 400 is the same as 'normal' */
@@ -135,6 +285,8 @@
     --font-weight-h2: 400;
     --font-weight-h3: 400;
     --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);
 
     /* spacing */
     --spacing-xxs: 1px;
@@ -167,7 +319,7 @@
     --diff-blank-background-color: var(--background-color-secondary);
     --diff-context-control-background-color: #fff7d4;
     --diff-context-control-border-color: #f6e6a5;
-    --diff-context-control-color: var(--deemphasized-text-color);
+    --diff-context-control-color: var(--default-button-text-color);
     --diff-highlight-range-color: rgba(255, 213, 0, 0.5);
     --diff-highlight-range-hover-color: rgba(255, 255, 0, 0.5);
     --diff-selection-background-color: #c7dbf9;
@@ -175,11 +327,15 @@
     --diff-trailing-whitespace-indicator: #ff9ad2;
     --light-add-highlight-color: #d8fed8;
     --light-rebased-add-highlight-color: #eef;
-    --light-moved-add-highlight-color: #eef;
+    --diff-moved-in-background: var(--cyan-50);
+    --diff-moved-out-background: var(--purple-50);
+    --diff-moved-in-label-color: var(--cyan-900);
+    --diff-moved-out-label-color: var(--purple-900);
     --light-remove-add-highlight-color: #fff8dc;
     --light-remove-highlight-color: #ffebee;
     --coverage-covered: #e0f2f1;
     --coverage-not-covered: #ffd1a4;
+    --ranged-comment-hint-text-color: var(--orange-900);
 
     /* syntax colors */
     --syntax-attr-color: #219;
@@ -207,7 +363,7 @@
     --syntax-template-tag-color: #fa8602;
     --syntax-template-variable-color: #0000c0;
     --syntax-title-color: #0000c0;
-    --syntax-type-color: #2a66d9;
+    --syntax-type-color: var(--blue-700);
     --syntax-variable-color: var(--primary-text-color);
 
     /* elevation */
@@ -220,6 +376,8 @@
     /* 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==');
 
     /* paper and iron component overrides */
     --iron-overlay-backdrop-background-color: black;
@@ -227,6 +385,16 @@
     --iron-overlay-backdrop: {
       transition: none;
     };
+    --paper-tooltip-delay-in: 200ms;
+    --paper-tooltip-delay-out: 0;
+    --paper-tooltip-duration-in: 0;
+    --paper-tooltip-duration-out: 0;
+    --paper-tooltip-background: var(--tooltip-background-color);
+    --paper-tooltip-opacity: 1.0;
+    --paper-tooltip-text-color: var(--tooltip-text-color);
+    --paper-tooltip: {
+      font-size: var(--font-size-small);
+    }
   }
   @media screen and (max-width: 50em) {
     html {
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 0b54da4..77ceb30 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -36,28 +36,74 @@
        * you probably want to override all.
        */
 
-      /* text colors */
-      --primary-text-color: #e8eaed;
-      --link-color: #8ab4f8;
-      --comment-text-color: var(--primary-text-color);
-      --deemphasized-text-color: #9aa0a6;
-      --default-button-text-color: #8ab4f8;
-      --chip-selected-text-color: #d2e3fc;
-      --error-text-color: red;
+      --error-foreground: var(--red-200);
+      --error-background: var(--red-tonal);
+      --error-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--red-tonal);
+      --error-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--red-tonal);
+      --error-ripple: var(--white-10);
+
+      --warning-foreground: var(--orange-200);
+      --warning-background: var(--orange-tonal);
+      --warning-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--orange-tonal);
+      --warning-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--orange-tonal);
+      --warning-ripple: var(--white-10);
+
+      --info-foreground: var(--blue-200);
+      --info-background: var(--blue-tonal);
+      --info-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--blue-tonal);
+      --info-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--blue-tonal);
+      --info-ripple: var(--white-10);
+
       --primary-button-text-color: black;
-        /* Used on text color for change list doesn't need user's attention. */
-      --reviewed-text-color: #dadce0;
+      --primary-button-background-color: var(--gerrit-blue-dark);
+      --primary-button-background-hover: var(--blue-200-16);
+      --primary-button-background-focus: var(--blue-200-24);
+
+      --selected-foreground: var(--blue-200);
+      --selected-background: var(--blue-900);
+
+      --success-foreground: var(--green-200);
+      --success-background: var(--green-tonal);
+      --success-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--green-tonal);
+      --success-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--green-tonal);
+      --success-ripple: var(--white-10);
+
+      --gray-foreground: var(--gray-300);
+      --gray-background: var(--gray-tonal);
+      --gray-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--gray-tonal);
+      --gray-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--gray-tonal);
+      --gray-ripple: var(--white-10);
+
+      --disabled-foreground: var(--gray-200-38);
+      --disabled-background: var(--gray-200-12);
+
+      --chip-color: var(--gray-100);
+      --error-color: var(--red-200);
+      --tag-background: var(--cyan-900);
+      --label-background: var(--red-900);
+
+      /* text colors */
+      --primary-text-color: var(--gray-200);
+      --link-color: var(--gerrit-blue-dark);
+      --comment-text-color: var(--primary-text-color);
+      --deemphasized-text-color: var(--gray-500);
+      --default-button-text-color: var(--gerrit-blue-dark);
+      --chip-selected-text-color: var(--blue-100);
+      --error-text-color: var(--red-200);
+      /* Used on text color for change list doesn't need user's attention. */
+      --reviewed-text-color: var(--gray-300);
       --vote-text-color: black;
       --status-text-color: black;
-      --tooltip-text-color: white;
-      --negative-red-text-color: #f28b82;
-      --positive-green-text-color: #81c995;
+      --tooltip-text-color: var(--gray-200);
+      --negative-red-text-color: var(--red-200);
+      --positive-green-text-color: var(--green-200);
+      --indirect-ancestor-text-color: var(--green-200);
 
       /* background colors */
       /* primary background colors */
-      --background-color-primary: #202124;
+      --background-color-primary: var(--gray-900);
       --background-color-secondary: #2f3034;
-      --background-color-tertiary: #3b3d3f;
+      --background-color-tertiary: var(--gray-800);
       /* directly derived from primary background colors */
       /*   empty, because inheriting from app-theme is just fine
       /* unique background colors */
@@ -68,33 +114,51 @@
       --emphasis-color: #383f4a;
       --hover-background-color: rgba(161, 194, 250, 0.2);
       --disabled-button-background-color: #484a4d;
-      --primary-button-background-color: var(--link-color);
       --selection-background-color: rgba(161, 194, 250, 0.1);
-      --tooltip-background-color: #111;
+      --tooltip-background-color: var(--gray-800);
+
       /* comment background colors */
       --comment-background-color: #3c3f43;
       --robot-comment-background-color: #1e3a5f;
       --unresolved-comment-background-color: #614a19;
+
       /* vote background colors */
-      --vote-color-approved: #7fb66b;
-      --vote-color-disliked: #bf6874;
-      --vote-color-neutral: #597280;
-      --vote-color-recommended: #3f6732;
-      --vote-color-rejected: #ac2d3e;
+      --vote-color-approved: var(--green-300);
+      --vote-color-disliked: var(--red-tonal);
+      --vote-outline-disliked: var(--red-200);
+      --vote-color-neutral: var(--gray-700);
+      --vote-color-recommended: var(--green-tonal);
+      --vote-outline-recommended: var(--green-200);
+      --vote-color-rejected: var(--red-200);
+
+      --outline-color-focus: var(--gray-100);
 
       /* misc colors */
-      --border-color: #5f6368;
+      --border-color: var(--gray-700);
       --comment-separator-color: var(--border-color);
 
+      /* checks tag colors */
+      --tag-gray: var(--gray-tonal);
+      --tag-yellow: var(--yellow-tonal);
+      --tag-pink: var(--pink-tonal);
+      --tag-purple: var(--purple-tonal);
+      --tag-cyan: var(--cyan-tonal);
+      --tag-brown: var(--brown-tonal);
+
       /* status colors */
-      --status-merged: #5bb974;
-      --status-abandoned: #dadce0;
+      --status-merged: var(--green-400);
+      --status-abandoned: var(--gray-300);
       --status-wip: #bcaaa4;
-      --status-private: #d7aefb;
-      --status-conflict: #f28b82;
-      --status-active: #669df6;
-      --status-ready: #f439a0;
-      --status-custom: #af5cf7;
+      --status-private: var(--purple-200);
+      --status-conflict: var(--red-300);
+      --status-active: var(--blue-400);
+      --status-ready: var(--pink-500);
+      --status-custom: var(--purple-400);
+
+      /* file status colors */
+      --file-status-added: var(--green-tonal);
+      --file-status-changed: var(--red-tonal);
+      --file-status-unchanged: var(--grey-700);
 
       /* fonts */
       --font-weight-bold: 700; /* 700 is the same as 'bold' */
@@ -125,11 +189,15 @@
       --diff-trailing-whitespace-indicator: #ff9ad2;
       --light-add-highlight-color: #0f401f;
       --light-rebased-add-highlight-color: #487165;
-      --light-moved-add-highlight-color: #487165;
+      --diff-moved-in-background: #1d4042;
+      --diff-moved-out-background: #230e34;
+      --diff-moved-in-label-color: var(--cyan-50);
+      --diff-moved-out-label-color: var(--purple-50);
       --light-remove-add-highlight-color: #2f3f2f;
       --light-remove-highlight-color: #320404;
       --coverage-covered: #112826;
       --coverage-not-covered: #6b3600;
+      --ranged-comment-hint-text-color: var(--blue-50);
 
       /* syntax colors */
       --syntax-attr-color: #80cbbf;
@@ -161,6 +229,8 @@
       --syntax-variable-color: #f77669;
 
       /* misc */
+      /* Base 64 encoded 1x1px of #d7aefb; */
+      --line-length-indicator: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2O4vu73fwAIYAOAtqAXCQAAAABJRU5ErkJggg==');
 
       /* paper and iron component overrides */
       --iron-overlay-backdrop-background-color: white;
diff --git a/polygerrit-ui/app/styles/themes/dark-theme_test.js b/polygerrit-ui/app/styles/themes/dark-theme_test.ts
similarity index 78%
rename from polygerrit-ui/app/styles/themes/dark-theme_test.js
rename to polygerrit-ui/app/styles/themes/dark-theme_test.ts
index 4f6466f..16e609e 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme_test.js
+++ b/polygerrit-ui/app/styles/themes/dark-theme_test.ts
@@ -15,14 +15,14 @@
  * limitations under the License.
  */
 
-import '../../test/common-test-setup-karma.js';
-import {applyTheme, removeTheme} from './dark-theme.js';
+import '../../test/common-test-setup-karma';
+import {applyTheme, removeTheme} from './dark-theme';
 
-suite('dark-theme_test.js', () => {
+suite('dark-theme test', () => {
   test('apply and remove theme', () => {
     applyTheme();
     assert.equal(document.head.querySelectorAll('#dark-theme').length, 1);
     removeTheme();
-    assert.equal(document.head.querySelectorAll('#dark-theme').length, 0);
+    assert.isEmpty(document.head.querySelectorAll('#dark-theme'));
   });
 });
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 71c45f7..641e4b3 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -23,17 +23,17 @@
 import './test-router';
 import {_testOnlyInitAppContext} from './test-app-context-init';
 import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
-import {_testOnlyResetRestApi} from '../elements/shared/gr-js-api-interface/gr-plugin-rest-api';
 import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface';
 import {
   cleanupTestUtils,
   getCleanupsCount,
   registerTestCleanup,
+  addIronOverlayBackdropStyleEl,
+  removeIronOverlayBackdropStyleEl,
   TestKeyboardShortcutBinder,
 } from './test-utils';
-import {flushDebouncers} from '@polymer/polymer/lib/utils/debounce';
 import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import sinon, {SinonSpy} from 'sinon/pkg/sinon-esm';
+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';
@@ -42,7 +42,8 @@
   _testOnly_defaultResinReportHandler,
   installPolymerResin,
 } from '../scripts/polymer-resin-install';
-import {hasOwnProperty} from '../utils/common-util';
+import {_testOnly_allTasks} from '../utils/async-util';
+import {cleanUpStorage} from '../services/storage/gr-storage_mock';
 
 declare global {
   interface Window {
@@ -90,10 +91,13 @@
 }
 
 window.fixture = fixtureImpl;
+let testSetupTimestampMs = 0;
 
 setup(() => {
+  testSetupTimestampMs = new Date().getTime();
   window.Gerrit = {};
   initGlobalVariables();
+  addIronOverlayBackdropStyleEl();
 
   // If the following asserts fails - then window.stub is
   // overwritten by some other code.
@@ -119,30 +123,22 @@
   // to reset this behavior if you need to test something specific.
   pl.loadPlugins([]);
   _testOnlyResetGrRestApiSharedObjects();
-  _testOnlyResetRestApi();
 });
 
 // For karma always set our implementation
 // (karma doesn't provide the stub method)
-function stubImpl<T extends keyof HTMLElementTagNameMap>(
-  tagName: T,
-  implementation: Partial<HTMLElementTagNameMap[T]>
-) {
+function stubImpl<
+  T extends keyof HTMLElementTagNameMap,
+  K extends keyof HTMLElementTagNameMap[T]
+>(tagName: T, method: K) {
   // This method is inspired by web-component-tester method
   const proto = document.createElement(tagName).constructor
     .prototype as HTMLElementTagNameMap[T];
-  let key: keyof HTMLElementTagNameMap[T];
-  const stubs: SinonSpy[] = [];
-  for (key in implementation) {
-    if (hasOwnProperty(implementation, key)) {
-      stubs.push(sinon.stub(proto, key).callsFake(implementation[key]));
-    }
-  }
+  const stub = sinon.stub(proto, method);
   registerTestCleanup(() => {
-    stubs.forEach(stub => {
-      stub.restore();
-    });
+    stub.restore();
   });
+  return stub;
 }
 
 window.stub = stubImpl;
@@ -191,11 +187,24 @@
   }
 }
 
+function cancelAllTasks() {
+  for (const task of _testOnly_allTasks.values()) {
+    console.warn('ATTENTION! A task was still active at the end of the test!');
+    task.cancel();
+  }
+}
+
 teardown(() => {
   sinon.restore();
   cleanupTestUtils();
   TestKeyboardShortcutBinder.pop();
   checkGlobalSpace();
-  // Clean Polymer debouncer queue, so next tests will not be affected.
-  flushDebouncers();
+  removeIronOverlayBackdropStyleEl();
+  cancelAllTasks();
+  cleanUpStorage();
+  const testTeardownTimestampMs = new Date().getTime();
+  const elapsedMs = testTeardownTimestampMs - testSetupTimestampMs;
+  if (elapsedMs > 1000) {
+    console.warn(`ATTENTION! Test took longer than 1 second: ${elapsedMs} ms`);
+  }
 });
diff --git a/polygerrit-ui/app/test/mocks/comment-api.js b/polygerrit-ui/app/test/mocks/comment-api.js
index f2ca48c..526aabe 100644
--- a/polygerrit-ui/app/test/mocks/comment-api.js
+++ b/polygerrit-ui/app/test/mocks/comment-api.js
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 
@@ -23,9 +22,7 @@
  * This is an "abstract" class for tests. The descendant must define a template
  * for this element and a tagName - see createCommentApiMockWithTemplateElement below
  */
-class CommentApiMock extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
+class CommentApiMock extends LegacyElementMixin(PolymerElement) {
   static get properties() {
     return {
       _changeComments: Object,
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
new file mode 100644
index 0000000..90b6125
--- /dev/null
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -0,0 +1,536 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {
+  AccountDetailInfo,
+  AccountExternalIdInfo,
+  AccountInfo,
+  ServerInfo,
+  ProjectInfo,
+  AccountCapabilityInfo,
+  SuggestedReviewerInfo,
+  GroupNameToGroupInfoMap,
+  ParsedJSON,
+  EditPreferencesInfo,
+  SshKeyInfo,
+  RepoName,
+  GpgKeyInfo,
+  PreferencesInfo,
+  EmailInfo,
+  ProjectAccessInfo,
+  CapabilityInfoMap,
+  ChangeInfo,
+  ProjectInfoWithName,
+  GroupInfo,
+  BranchInfo,
+  ConfigInfo,
+  EditInfo,
+  DashboardInfo,
+  ProjectAccessInfoMap,
+  IncludedInInfo,
+  CommentInfo,
+  PathToCommentsInfoMap,
+  PluginInfo,
+  DocResult,
+  ContributorAgreementInfo,
+  Password,
+  ProjectWatchInfo,
+  NameToProjectInfoMap,
+  GroupAuditEventInfo,
+  Base64FileContent,
+  TagInfo,
+  RelatedChangesInfo,
+  SubmittedTogetherInfo,
+  FilePathToDiffInfoMap,
+  BlameInfo,
+  ImagesForDiff,
+  ActionNameToActionInfoMap,
+  Hashtag,
+  FileNameToFileInfoMap,
+  TopMenuEntryInfo,
+  MergeableInfo,
+  CommitInfo,
+  GroupId,
+  GroupName,
+  UrlEncodedRepoName,
+} from '../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../types/diff';
+import {readResponsePayload} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {
+  createAccountDetailWithId,
+  createChange,
+  createCommit,
+  createConfig,
+  createPreferences,
+  createServerInfo,
+  createSubmittedTogetherInfo,
+} from '../test-data-generators';
+import {
+  createDefaultDiffPrefs,
+  createDefaultEditPrefs,
+} from '../../constants/constants';
+import {ParsedChangeInfo} from '../../types/types';
+
+export const grRestApiMock: RestApiService = {
+  addAccountEmail(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  addAccountGPGKey(): Promise<Record<string, GpgKeyInfo>> {
+    return Promise.resolve({});
+  },
+  addAccountSSHKey(): Promise<SshKeyInfo> {
+    throw new Error('addAccountSSHKey() not implemented by RestApiMock.');
+  },
+  addToAttentionSet(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  applyFixSuggestion(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  awaitPendingDiffDrafts(): Promise<void> {
+    return Promise.resolve();
+  },
+  confirmEmail(): Promise<string | null> {
+    return Promise.resolve('');
+  },
+  createChange(): Promise<ChangeInfo | undefined> {
+    throw new Error('createChange() not implemented by RestApiMock.');
+  },
+  createGroup(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  createRepo(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  createRepoBranch(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  createRepoTag(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteAccountEmail(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteAccountGPGKey(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteAccountIdentity(): Promise<unknown> {
+    return Promise.resolve(new Response());
+  },
+  deleteAccountSSHKey(): void {},
+  deleteAssignee(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteChangeCommitMessage(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteComment(): Promise<CommentInfo> {
+    throw new Error('deleteComment() not implemented by RestApiMock.');
+  },
+  deleteDiffDraft(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteDraftComments(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteFileInChangeEdit(): Promise<Response | undefined> {
+    return Promise.resolve(new Response());
+  },
+  deleteGroupMember(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteIncludedGroup(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteRepoBranches(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteRepoTags(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteVote(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteWatchedProjects(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  executeChangeAction(): Promise<Response | undefined> {
+    return Promise.resolve(new Response());
+  },
+  generateAccountHttpPassword(): Promise<Password> {
+    return Promise.resolve('asdf');
+  },
+  getAccount(): Promise<AccountDetailInfo | undefined> {
+    return Promise.resolve(createAccountDetailWithId(1));
+  },
+  getAccountAgreements(): Promise<ContributorAgreementInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getAccountCapabilities(): Promise<AccountCapabilityInfo | undefined> {
+    return Promise.resolve({});
+  },
+  getAccountDetails(): Promise<AccountDetailInfo | undefined> {
+    return Promise.resolve(createAccountDetailWithId(1));
+  },
+  getAccountEmails(): Promise<EmailInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getAccountGPGKeys(): Promise<Record<string, GpgKeyInfo>> {
+    return Promise.resolve({});
+  },
+  getAccountGroups(): Promise<GroupInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getAccountSSHKeys(): Promise<SshKeyInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getAccountStatus(): Promise<string | undefined> {
+    return Promise.resolve('');
+  },
+  getAvatarChangeUrl(): Promise<string | undefined> {
+    return Promise.resolve('');
+  },
+  getBlame(): Promise<BlameInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getCapabilities(): Promise<CapabilityInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getChange(): Promise<ChangeInfo | null> {
+    throw new Error('getChange() not implemented by RestApiMock.');
+  },
+  getChangeActionURL(): Promise<string> {
+    return Promise.resolve('');
+  },
+  getChangeCherryPicks(): Promise<ChangeInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getChangeCommitInfo(): Promise<CommitInfo | undefined> {
+    return Promise.resolve(createCommit());
+  },
+  getChangeConflicts(): Promise<ChangeInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getChangeDetail(): Promise<ParsedChangeInfo | null | undefined> {
+    return Promise.resolve(createChange() as ParsedChangeInfo);
+  },
+  getChangeEdit(): Promise<false | EditInfo | undefined> {
+    return Promise.resolve(false);
+  },
+  getChangeFiles(): Promise<FileNameToFileInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getChangeIncludedIn(): Promise<IncludedInInfo | undefined> {
+    throw new Error('getChangeIncludedIn() not implemented by RestApiMock.');
+  },
+  getChangeOrEditFiles(): Promise<FileNameToFileInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getChangeRevisionActions(): Promise<ActionNameToActionInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getChangeSuggestedCCs(): Promise<SuggestedReviewerInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getChangeSuggestedReviewers(): Promise<SuggestedReviewerInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getChanges() {
+    return Promise.resolve([]);
+  },
+  getChangesSubmittedTogether(): Promise<SubmittedTogetherInfo | undefined> {
+    return Promise.resolve(createSubmittedTogetherInfo());
+  },
+  getChangesWithSameTopic(): Promise<ChangeInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getChangesWithSimilarTopic(): Promise<ChangeInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getConfig(): Promise<ServerInfo | undefined> {
+    return Promise.resolve(createServerInfo());
+  },
+  getDashboard(): Promise<DashboardInfo | undefined> {
+    throw new Error('getDashboard() not implemented by RestApiMock.');
+  },
+  getDefaultPreferences(): Promise<PreferencesInfo | undefined> {
+    throw new Error('getDefaultPreferences() not implemented by RestApiMock.');
+  },
+  getDiff(): Promise<DiffInfo | undefined> {
+    throw new Error('getDiff() not implemented by RestApiMock.');
+  },
+  getDiffChangeDetail(): Promise<ChangeInfo | undefined | null> {
+    throw new Error('getDiffChangeDetail() not implemented by RestApiMock.');
+  },
+  getDiffComments() {
+    throw new Error('getDiffComments() not implemented by RestApiMock.');
+  },
+  getDiffDrafts() {
+    throw new Error('getDiffDrafts() not implemented by RestApiMock.');
+  },
+  getDiffPreferences(): Promise<DiffPreferencesInfo | undefined> {
+    return Promise.resolve(createDefaultDiffPrefs());
+  },
+  getDiffRobotComments() {
+    throw new Error('getDiffRobotComments() not implemented by RestApiMock.');
+  },
+  getDocumentationSearches(): Promise<DocResult[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getEditPreferences(): Promise<EditPreferencesInfo | undefined> {
+    return Promise.resolve(createDefaultEditPrefs());
+  },
+  getExternalIds(): Promise<AccountExternalIdInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getFileContent(): Promise<Response | Base64FileContent | undefined> {
+    return Promise.resolve(new Response());
+  },
+  getFromProjectLookup(): Promise<RepoName | undefined> {
+    throw new Error('getFromProjectLookup() not implemented by RestApiMock.');
+  },
+  getGroupAuditLog(): Promise<GroupAuditEventInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getGroupConfig(id: GroupId | GroupName): Promise<GroupInfo | undefined> {
+    return Promise.resolve({
+      id: id as GroupId,
+    });
+  },
+  getGroupMembers(): Promise<AccountInfo[]> {
+    return Promise.resolve([]);
+  },
+  getGroups(): Promise<GroupNameToGroupInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getImagesForDiff(): Promise<ImagesForDiff> {
+    throw new Error('getImagesForDiff() not implemented by RestApiMock.');
+  },
+  getIncludedGroup(): Promise<GroupInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getIsAdmin(): Promise<boolean | undefined> {
+    return Promise.resolve(false);
+  },
+  getIsGroupOwner(): Promise<boolean> {
+    return Promise.resolve(false);
+  },
+  getLoggedIn(): Promise<boolean> {
+    return Promise.resolve(true);
+  },
+  getMergeable(): Promise<MergeableInfo | undefined> {
+    throw new Error('getMergeable() not implemented by RestApiMock.');
+  },
+  getPlugins(): Promise<{[p: string]: PluginInfo} | undefined> {
+    return Promise.resolve({});
+  },
+  getPortedComments(): Promise<PathToCommentsInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getPortedDrafts(): Promise<PathToCommentsInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getPreferences(): Promise<PreferencesInfo | undefined> {
+    return Promise.resolve(createPreferences());
+  },
+  getProjectConfig(): Promise<ConfigInfo | undefined> {
+    return Promise.resolve(createConfig());
+  },
+  getRelatedChanges(): Promise<RelatedChangesInfo | undefined> {
+    return Promise.resolve({changes: []});
+  },
+  getRepo(repo: RepoName): Promise<ProjectInfo | undefined> {
+    return Promise.resolve({
+      id: (repo as string) as UrlEncodedRepoName,
+      name: repo,
+    });
+  },
+  getRepoAccess(): Promise<ProjectAccessInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getRepoAccessRights(): Promise<ProjectAccessInfo | undefined> {
+    return Promise.resolve(undefined);
+  },
+  getRepoBranches(): Promise<BranchInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getRepoDashboards(): Promise<DashboardInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getRepoTags(): Promise<TagInfo[]> {
+    return Promise.resolve([]);
+  },
+  getRepos(): Promise<ProjectInfoWithName[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getResponseObject(response: Response): Promise<ParsedJSON> {
+    return readResponsePayload(response).then(payload => payload.parsed);
+  },
+  getReviewedFiles(): Promise<string[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getRobotCommentFixPreview(): Promise<FilePathToDiffInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getSuggestedAccounts(): Promise<AccountInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getSuggestedGroups(): Promise<GroupNameToGroupInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getSuggestedProjects(): Promise<NameToProjectInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getTopMenus(): Promise<TopMenuEntryInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getVersion(): Promise<string | undefined> {
+    return Promise.resolve('');
+  },
+  getWatchedProjects(): Promise<ProjectWatchInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  hasPendingDiffDrafts(): number {
+    return 0;
+  },
+  invalidateAccountsCache(): void {},
+  invalidateGroupsCache(): void {},
+  invalidateReposCache(): void {},
+  invalidateAccountsDetailCache(): void {},
+  probePath(): Promise<boolean> {
+    return Promise.resolve(true);
+  },
+  putChangeCommitMessage(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  queryChangeFiles(): Promise<string[] | undefined> {
+    return Promise.resolve([]);
+  },
+  removeChangeReviewer(): Promise<Response | undefined> {
+    return Promise.resolve(new Response());
+  },
+  removeFromAttentionSet(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  renameFileInChangeEdit(): Promise<Response | undefined> {
+    return Promise.resolve(new Response());
+  },
+  restoreFileInChangeEdit(): Promise<Response | undefined> {
+    return Promise.resolve(new Response());
+  },
+  runRepoGC(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveAccountAgreement(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveChangeEdit(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveChangeReview() {
+    return Promise.resolve(new Response());
+  },
+  saveChangeReviewed(): Promise<Response | undefined> {
+    return Promise.resolve(new Response());
+  },
+  saveChangeStarred(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveDiffDraft(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveDiffPreferences(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveEditPreferences(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveFileReviewed(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveFileUploadChangeEdit(): Promise<Response | undefined> {
+    return Promise.resolve(new Response());
+  },
+  saveGroupDescription(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveGroupMember(): Promise<AccountInfo> {
+    return Promise.resolve({});
+  },
+  saveGroupName(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveGroupOptions(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveGroupOwner(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveIncludedGroup(): Promise<GroupInfo | undefined> {
+    throw new Error('saveIncludedGroup() not implemented by RestApiMock.');
+  },
+  savePreferences(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveRepoConfig(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveWatchedProjects(): Promise<ProjectWatchInfo[]> {
+    return Promise.resolve([]);
+  },
+  send() {
+    return Promise.resolve(new Response());
+  },
+  setAccountDisplayName(): Promise<void> {
+    return Promise.resolve();
+  },
+  setAccountName(): Promise<void> {
+    return Promise.resolve();
+  },
+  setAccountStatus(): Promise<void> {
+    return Promise.resolve();
+  },
+  setAccountUsername(): Promise<void> {
+    return Promise.resolve();
+  },
+  setAssignee(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  setChangeHashtag(): Promise<Hashtag[]> {
+    return Promise.resolve([]);
+  },
+  setChangeTopic(): Promise<string> {
+    return Promise.resolve('');
+  },
+  setDescription(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  setInProjectLookup(): void {},
+  setPreferredAccountEmail(): Promise<void> {
+    return Promise.resolve();
+  },
+  setRepoAccessRights(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  setRepoAccessRightsForReview(): Promise<ChangeInfo> {
+    throw new Error('setRepoAccessRightsForReview() not implemented by mock.');
+  },
+  setRepoHead(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+};
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 7f19903..d74a9c1 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -19,6 +19,8 @@
 import {initAppContext} from '../services/app-context-init';
 import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
 import {AppContext, appContext} from '../services/app-context';
+import {grRestApiMock} from './mocks/gr-rest-api_mock';
+import {grStorageMock} from '../services/storage/gr-storage_mock';
 
 export function _testOnlyInitAppContext() {
   initAppContext();
@@ -34,4 +36,6 @@
     });
   }
   setMock('reportingService', grReportingMock);
+  setMock('restApiService', grRestApiMock);
+  setMock('storageService', grStorageMock);
 }
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 3b464a5..778e58d 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -58,6 +58,15 @@
   TimezoneOffset,
   UserConfigInfo,
   AccountDetailInfo,
+  Requirement,
+  RequirementType,
+  UrlEncodedCommentId,
+  BasePatchSetNum,
+  RelatedChangeAndCommitInfo,
+  SubmittedTogetherInfo,
+  RelatedChangesInfo,
+  FixSuggestionInfo,
+  FixId,
 } from '../types/common';
 import {
   AccountsVisibility,
@@ -74,15 +83,19 @@
   RevisionKind,
   SubmitType,
   TimeFormat,
+  RequirementStatus,
+  CommentSide,
 } from '../constants/constants';
 import {formatDate} from '../utils/date-util';
-import {GetDiffCommentsOutput} from '../services/services/gr-rest-api/gr-rest-api';
+import {GetDiffCommentsOutput} from '../services/gr-rest-api/gr-rest-api';
 import {AppElementChangeViewParams} from '../elements/gr-app-types';
-import {GerritView} from '../elements/core/gr-navigation/gr-navigation';
-import {
-  EditRevisionInfo,
-  ParsedChangeInfo,
-} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {CommitInfoWithRequiredCommit} from '../elements/change/gr-change-metadata/gr-change-metadata';
+import {WebLinkInfo} from '../types/diff';
+import {UIComment, UIDraft, createCommentThreads} from '../utils/comment-util';
+import {GerritView} from '../services/router/router-model';
+import {ChangeComments} from '../elements/diff/gr-comment-api/gr-comment-api';
+import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
+import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 
 export function dateToTimestamp(date: Date): Timestamp {
   const nanosecondSuffix = '.000000000';
@@ -194,6 +207,15 @@
   };
 }
 
+export function createCommitInfoWithRequiredCommit(
+  commit = 'commit'
+): CommitInfoWithRequiredCommit {
+  return {
+    ...createCommit(),
+    commit: commit as CommitId,
+  };
+}
+
 export function createRevision(patchSetNum = 1): RevisionInfo {
   return {
     _number: patchSetNum as PatchSetNum,
@@ -208,12 +230,12 @@
 export function createEditRevision(): EditRevisionInfo {
   return {
     _number: EditPatchSetNum,
-    basePatchNum: 1 as PatchSetNum,
+    basePatchNum: 1 as BasePatchSetNum,
     commit: createCommit(),
   };
 }
 
-export function createChangeMessage(id = 'cm_id_1'): ChangeMessageInfo {
+export function createChangeMessageInfo(id = 'cm_id_1'): ChangeMessageInfo {
   return {
     id: id as ChangeMessageId,
     date: dateToTimestamp(TEST_CHANGE_CREATED),
@@ -221,6 +243,15 @@
   };
 }
 
+export function createChangeMessage(id = 'cm_id_1'): ChangeMessage {
+  return {
+    ...createChangeMessageInfo(id),
+    type: '',
+    expanded: false,
+    commentThreads: [],
+  };
+}
+
 export function createRevisions(
   count: number
 ): {[revisionId: string]: RevisionInfo} {
@@ -252,7 +283,7 @@
   const messageDate = TEST_CHANGE_CREATED;
   for (let i = 0; i < count; i++) {
     messages.push({
-      ...createChangeMessage((i + messageIdStart).toString(16)),
+      ...createChangeMessageInfo((i + messageIdStart).toString(16)),
       date: dateToTimestamp(messageDate),
     });
     messageDate.setDate(messageDate.getDate() + 1);
@@ -345,7 +376,6 @@
   return {
     has_avatars: false,
     js_resource_paths: [],
-    html_resource_paths: [],
   };
 }
 
@@ -388,6 +418,7 @@
   };
 }
 
+// TODO: Maybe reconcile with createDefaultPreferences() in constants.ts.
 export function createPreferences(): PreferencesInfo {
   return {
     changes_per_page: 10,
@@ -413,3 +444,183 @@
     project: TEST_PROJECT_NAME,
   };
 }
+
+export function createRequirement(): Requirement {
+  return {
+    status: RequirementStatus.OK,
+    fallbackText: '',
+    type: 'wip' as RequirementType,
+  };
+}
+
+export function createWebLinkInfo(): WebLinkInfo {
+  return {
+    name: 'gitiles',
+    url: '#',
+    image_url: 'gitiles.jpg',
+  };
+}
+
+export function createComment(): UIComment {
+  return {
+    patch_set: 1 as PatchSetNum,
+    id: '12345' as UrlEncodedCommentId,
+    side: CommentSide.REVISION,
+    line: 1,
+    message: 'hello world',
+    updated: '2018-02-13 22:48:48.018000000' as Timestamp,
+    unresolved: false,
+    path: 'abc.txt',
+  };
+}
+
+export function createDraft(): UIDraft {
+  return {
+    ...createComment(),
+    collapsed: false,
+    __draft: true,
+    __editing: false,
+  };
+}
+
+export function createChangeComments(): ChangeComments {
+  const comments = {
+    '/COMMIT_MSG': [
+      {
+        ...createComment(),
+        message: 'Done',
+        updated: '2017-02-08 16:40:49' as Timestamp,
+        id: '1' as UrlEncodedCommentId,
+      },
+      {
+        ...createComment(),
+        message: 'oh hay',
+        updated: '2017-02-09 16:40:49' as Timestamp,
+        id: '2' as UrlEncodedCommentId,
+      },
+      {
+        ...createComment(),
+        patch_set: 2 as PatchSetNum,
+        message: 'hello',
+        updated: '2017-02-10 16:40:49' as Timestamp,
+        id: '3' as UrlEncodedCommentId,
+      },
+    ],
+    'myfile.txt': [
+      {
+        ...createComment(),
+        message: 'good news!',
+        updated: '2017-02-08 16:40:49' as Timestamp,
+        id: '4' as UrlEncodedCommentId,
+      },
+      {
+        ...createComment(),
+        patch_set: 2 as PatchSetNum,
+        message: 'wat!?',
+        updated: '2017-02-09 16:40:49' as Timestamp,
+        id: '5' as UrlEncodedCommentId,
+      },
+      {
+        ...createComment(),
+        patch_set: 2 as PatchSetNum,
+        message: 'hi',
+        updated: '2017-02-10 16:40:49' as Timestamp,
+        id: '6' as UrlEncodedCommentId,
+      },
+    ],
+    'unresolved.file': [
+      {
+        ...createComment(),
+        patch_set: 2 as PatchSetNum,
+        message: 'wat!?',
+        updated: '2017-02-09 16:40:49' as Timestamp,
+        id: '7' as UrlEncodedCommentId,
+        unresolved: true,
+      },
+      {
+        ...createComment(),
+        patch_set: 2 as PatchSetNum,
+        message: 'hi',
+        updated: '2017-02-10 16:40:49' as Timestamp,
+        id: '8' as UrlEncodedCommentId,
+        in_reply_to: '7' as UrlEncodedCommentId,
+        unresolved: false,
+      },
+      {
+        ...createComment(),
+        patch_set: 2 as PatchSetNum,
+        message: 'good news!',
+        updated: '2017-02-08 16:40:49' as Timestamp,
+        id: '9' as UrlEncodedCommentId,
+        unresolved: true,
+      },
+    ],
+  };
+  const drafts = {
+    '/COMMIT_MSG': [
+      {
+        ...createDraft(),
+        message: 'hi',
+        updated: '2017-02-15 16:40:49' as Timestamp,
+        id: '10' as UrlEncodedCommentId,
+        unresolved: true,
+      },
+      {
+        ...createDraft(),
+        message: 'fyi',
+        updated: '2017-02-15 16:40:49' as Timestamp,
+        id: '11' as UrlEncodedCommentId,
+        unresolved: false,
+      },
+    ],
+    'unresolved.file': [
+      {
+        ...createDraft(),
+        message: 'hi',
+        updated: '2017-02-11 16:40:49' as Timestamp,
+        id: '12' as UrlEncodedCommentId,
+        unresolved: false,
+      },
+    ],
+  };
+  return new ChangeComments(comments, {}, drafts, {}, {});
+}
+
+export function createCommentThread(comments: UIComment[]) {
+  if (!comments.length) {
+    throw new Error('comment is required to create a thread');
+  }
+  comments = comments.map(comment => {
+    return {...createComment(), ...comment};
+  });
+  const threads = createCommentThreads(comments);
+  return threads[0];
+}
+
+export function createRelatedChangeAndCommitInfo(): RelatedChangeAndCommitInfo {
+  return {
+    project: TEST_PROJECT_NAME,
+    commit: createCommitInfoWithRequiredCommit(),
+  };
+}
+
+export function createRelatedChangesInfo(): RelatedChangesInfo {
+  return {
+    changes: [],
+  };
+}
+
+export function createSubmittedTogetherInfo(): SubmittedTogetherInfo {
+  return {
+    changes: [],
+    non_visible_changes: 0,
+  };
+}
+
+export function createFixSuggestionInfo(fixId = 'fix_1'): FixSuggestionInfo {
+  return {
+    fix_id: fixId as FixId,
+    description: `Fix ${fixId}`,
+    replacements: [],
+  };
+}
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 7ebc6e1..26b99ac 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -16,12 +16,15 @@
  */
 import '../types/globals';
 import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
-import {testOnly_resetInternalState} from '../elements/shared/gr-js-api-interface/gr-api-utils';
 import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints';
 import {
   _testOnly_getShortcutManagerInstance,
   Shortcut,
 } from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {appContext} from '../services/app-context';
+import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
+import {SinonSpy} from 'sinon/pkg/sinon-esm';
+import {StorageService} from '../services/storage/gr-storage';
 
 export interface MockPromise extends Promise<unknown> {
   resolve: (value?: unknown) => void;
@@ -41,10 +44,31 @@
   return getComputedStyle(el).display === 'none';
 }
 
-export function query(el: Element | undefined, selectors: string) {
-  if (!el) return null;
-  const root = el.shadowRoot || el;
-  return root.querySelector(selectors);
+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
@@ -87,7 +111,6 @@
 // Provide reset plugins function to clear installed plugins between tests.
 // No gr-app found (running tests)
 export const resetPlugins = () => {
-  testOnly_resetInternalState();
   _testOnly_resetEndpoints();
   const pl = _testOnly_resetPluginLoader();
   pl.loadPlugins([]);
@@ -105,6 +128,25 @@
   cleanups.push(cleanupCallback);
 }
 
+export function addListenerForTest(
+  el: EventTarget,
+  type: string,
+  listener: EventListenerOrEventListenerObject
+) {
+  el.addEventListener(type, listener);
+  registerListenerCleanup(el, type, listener);
+}
+
+export function registerListenerCleanup(
+  el: EventTarget,
+  type: string,
+  listener: EventListenerOrEventListenerObject
+) {
+  registerTestCleanup(() => {
+    el.removeEventListener(type, listener);
+  });
+}
+
 export function cleanupTestUtils() {
   cleanups.forEach(cleanup => cleanup());
   cleanups.splice(0);
@@ -116,19 +158,39 @@
   registerTestCleanup(() => (window.CANONICAL_PATH = originalCanonicalPath));
 }
 
+export function stubRestApi<K extends keyof RestApiService>(method: K) {
+  return sinon.stub(appContext.restApiService, method);
+}
+
+export function spyRestApi<K extends keyof RestApiService>(method: K) {
+  return sinon.spy(appContext.restApiService, method);
+}
+
+export function stubStorage<K extends keyof StorageService>(method: K) {
+  return sinon.stub(appContext.storageService, method);
+}
+
+export type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
+  Parameters<F>,
+  ReturnType<F>
+>;
+
 /**
  * Forcing an opacity of 0 onto the ironOverlayBackdrop is required, because
  * otherwise the backdrop stays around in the DOM for too long waiting for
- * an animation to finish. This could be considered to be moved to a
- * common-test-setup file.
+ * an animation to finish.
  */
-export function createIronOverlayBackdropStyleEl() {
-  const ironOverlayBackdropStyleEl = document.createElement('style');
-  document.head.appendChild(ironOverlayBackdropStyleEl);
-  ironOverlayBackdropStyleEl.sheet!.insertRule(
-    'body { --iron-overlay-backdrop-opacity: 0; }'
-  );
-  return ironOverlayBackdropStyleEl;
+export function addIronOverlayBackdropStyleEl() {
+  const el = document.createElement('style');
+  el.setAttribute('id', 'backdrop-style');
+  document.head.appendChild(el);
+  el.sheet!.insertRule('body { --iron-overlay-backdrop-opacity: 0; }');
+}
+
+export function removeIronOverlayBackdropStyleEl() {
+  const el = document.getElementById('backdrop-style');
+  if (!el?.parentNode) throw new Error('Backdrop style element not found.');
+  el.parentNode?.removeChild(el);
 }
 
 /**
@@ -139,7 +201,7 @@
  *   ...
  */
 export function listenOnce(el: EventTarget, eventType: string) {
-  return new Promise(resolve => {
+  return new Promise<void>(resolve => {
     const listener = () => {
       removeEventListener();
       resolve();
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index 15294f4..46d0173d 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -48,7 +48,8 @@
   "include": [
     // Items below must be in sync with the src_dirs list in the BUILD file
     // Also items must be in sync with tsconfig_bazel.json, tsconfig_bazel_test.json
-    // (include and exclude arrays are overriden when extends)
+    // (include and exclude arrays are overridden when extends)
+    "api/**/*",
     "constants/**/*",
     "elements/**/*",
     "embed/**/*",
diff --git a/polygerrit-ui/app/tsconfig_bazel.json b/polygerrit-ui/app/tsconfig_bazel.json
index 6365bf0..dfd2078 100644
--- a/polygerrit-ui/app/tsconfig_bazel.json
+++ b/polygerrit-ui/app/tsconfig_bazel.json
@@ -9,7 +9,8 @@
   "include": [
     // Items below must be in sync with the src_dirs list in the BUILD file
     // Also items must be in sync with tsconfig.json, tsconfig_bazel_test.json
-    // (include and exclude arrays are overriden when extends)
+    // (include and exclude arrays are overridden when extends)
+    "api/**/*",
     "constants/**/*",
     "elements/**/*",
     "embed/**/*",
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
index efd2978..9c2ff93 100644
--- a/polygerrit-ui/app/tsconfig_bazel_test.json
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -14,7 +14,8 @@
   "include": [
     // Items below must be in sync with the src_dirs list in the BUILD file
     // Also items must be in sync with tsconfig.json, tsconfig_test.json
-    // (include and exclude arrays are overriden when extends)
+    // (include and exclude arrays are overridden when extends)
+    "api/**/*",
     "constants/**/*",
     "elements/**/*",
     "embed/**/*",
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 94b075d..3183510 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 
+import {CommentRange} from '../api/core';
 import {
   ChangeStatus,
   DefaultDisplayNameConfig,
@@ -37,7 +38,6 @@
   TimeFormat,
   EmailStrategy,
   DefaultBase,
-  IgnoreWhitespaceType,
   UserPriority,
   DiffViewMode,
   DraftsAction,
@@ -50,6 +50,10 @@
 } from '../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 
+import {DiffInfo, IgnoreWhitespaceType, WebLinkInfo} from './diff';
+
+export {CommentRange};
+
 export type BrandType<T, BrandName extends string> = T &
   {[__brand in BrandName]: never};
 
@@ -72,11 +76,14 @@
 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 PatchSetNum;
+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 PatchSetNum;
+export const ParentPatchSetNum = 'PARENT' as BasePatchSetNum;
 
 export type ChangeId = BrandType<string, '_changeId'>;
 export type ChangeMessageId = BrandType<string, '_changeMessageId'>;
@@ -194,7 +201,7 @@
   // This is not set when the change has no reviewers.
   all?: ApprovalInfo[];
   // Docs claim that 'values' is optional, but it is actually always set.
-  values: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
+  values?: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
   default_value?: number;
 }
 
@@ -261,7 +268,6 @@
   deletions: number; // Number of deleted lines
   total_comment_count?: number;
   unresolved_comment_count?: number;
-  // TODO(TS): Use changed_id everywhere in code instead of (legacy) _number
   _number: NumericChangeId;
   owner: AccountInfo;
   actions?: ActionNameToActionInfoMap;
@@ -547,7 +553,7 @@
   commit_with_footers?: boolean;
   push_certificate?: PushCertificateInfo;
   description?: string;
-  basePatchNum?: PatchSetNum;
+  basePatchNum?: BasePatchSetNum;
 }
 
 /**
@@ -716,16 +722,6 @@
 }
 
 /**
- * The WebLinkInfo entity describes a link to an external site.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#web-link-info
- */
-export interface WebLinkInfo {
-  name: string;
-  url: string;
-  image_url: string;
-}
-
-/**
  * The VotingRangeInfo entity describes the continuous voting range from minto
  * max values.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#voting-range-info
@@ -753,7 +749,7 @@
 export interface AuthInfo {
   auth_type: AuthType; // docs incorrectly names it 'type'
   use_contributor_agreements?: boolean;
-  contributor_agreements?: ContributorAgreementInfo;
+  contributor_agreements?: ContributorAgreementInfo[];
   editable_account_fields: EditableAccountField[];
   login_url?: string;
   login_text?: string;
@@ -1028,9 +1024,8 @@
  */
 export interface PluginConfigInfo {
   has_avatars: boolean;
-  // The following 2 properies exists in Java class, but don't mention in docs
+  // Exists in Java class, but not mentioned in docs.
   js_resource_paths: string[];
-  html_resource_paths: string[];
 }
 
 /**
@@ -1180,19 +1175,19 @@
   unresolved?: boolean;
   change_message_id?: string;
   commit_id?: string;
+  context_lines?: ContextLine[];
+  source_content_type?: string;
 }
 
 export type PathToCommentsInfoMap = {[path: string]: CommentInfo[]};
 
 /**
- * The CommentRange entity describes the range of an inline comment.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
+ * The ContextLine entity contains the line number and line text of a single line of the source file content..
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#context-line
  */
-export interface CommentRange {
-  start_line: number;
-  start_character: number;
-  end_line: number;
-  end_character: number;
+export interface ContextLine {
+  line_number: number;
+  context_line: string;
 }
 
 /**
@@ -1235,103 +1230,9 @@
 
 export type LabelTypeInfoValues = {[value: string]: string};
 
-/**
- * The DiffContent entity contains information about the content differences in a file.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-content
- */
-export interface DiffContent {
-  a?: string[];
-  b?: string[];
-  ab?: string[];
-  // The inner array is always of length two. The first entry is the 'skip'
-  // length. The second entry is the 'edit' length.
-  edit_a?: number[][];
-  edit_b?: number[][];
-  due_to_rebase?: boolean;
-  due_to_move?: boolean;
-  skip?: number;
-  common?: string;
-  keyLocation?: boolean;
-}
-
-/**
- * The DiffFileMetaInfo entity contains meta information about a file diff.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-file-meta-info
- */
-export interface DiffFileMetaInfo {
-  name: string;
-  content_type: string;
-  lines: string;
-  web_links?: WebLinkInfo[];
-  language?: string;
-}
-
-/**
- * The DiffInfo entity contains information about the diff of a file in a revision.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-info
- */
-export interface DiffInfo {
-  meta_a: DiffFileMetaInfo;
-  meta_b: DiffFileMetaInfo;
-  change_type: string;
-  intraline_status: string;
-  diff_header: string[];
-  content: DiffContent[];
-  web_links?: DiffWebLinkInfo[];
-  binary: boolean;
-}
-
 export type FilePathToDiffInfoMap = {[path: string]: DiffInfo};
 
 /**
- * The DiffWebLinkInfo entity describes a link on a diff screen to an external site.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-web-link-info
- */
-export interface DiffWebLinkInfo {
-  name: string;
-  url: string;
-  image_url: string;
-  show_on_side_by_side_diff_view: string;
-  show_on_unified_diff_view: string;
-}
-
-/**
- * The DiffPreferencesInfo entity contains information about the diff preferences of a user.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-info
- */
-export interface DiffPreferencesInfo {
-  context: number;
-  expand_all_comments?: boolean;
-  ignore_whitespace: IgnoreWhitespaceType;
-  intraline_difference?: boolean;
-  line_length: number;
-  cursor_blink_rate: number;
-  manual_review?: boolean;
-  retain_header?: boolean;
-  show_line_endings?: boolean;
-  show_tabs?: boolean;
-  show_whitespace_errors?: boolean;
-  skip_deleted?: boolean;
-  skip_uncommented?: boolean;
-  syntax_highlighting?: boolean;
-  hide_top_menu?: boolean;
-  auto_hide_diff_table_header?: boolean;
-  hide_line_numbers?: boolean;
-  tab_size: number;
-  font_size: number;
-  hide_empty_pane?: boolean;
-  match_brackets?: boolean;
-  line_wrapping?: boolean;
-  // TODO(TS): show_file_comment_button exists in JS code, but doesn't exist in the doc.
-  // Either remove or update doc
-  show_file_comment_button?: boolean;
-  // TODO(TS): theme exists in JS code, but doesn't exist in the doc.
-  // Either remove or update doc
-  theme?: string;
-}
-export type DiffPreferencesInfoKey = keyof DiffPreferencesInfo;
-
-/**
  * The RangeInfo entity stores the coordinates of a range.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#range-info
  */
@@ -1490,7 +1391,7 @@
   can_add_tags?: boolean;
   config_visible?: boolean;
   groups: ProjectAccessGroups;
-  config_web_links: string[];
+  config_web_links: WebLinkInfo[];
 }
 
 export type ProjectAccessInfoMap = {[projectName: string]: ProjectAccessInfo};
@@ -1716,12 +1617,12 @@
 }
 
 /**
- * Defines a patch ranges. Used as input for gr-rest-api-interface methods,
+ * Defines a patch ranges. Used as input for gr-rest-api methods,
  * doesn't exist in Rest API
  */
 export interface PatchRange {
-  patchNum: PatchSetNum;
-  basePatchNum: PatchSetNum;
+  patchNum: RevisionPatchSetNum;
+  basePatchNum: BasePatchSetNum;
 }
 
 /**
@@ -1782,7 +1683,6 @@
   context?: number;
   expand_all_comments?: boolean;
   ignore_whitespace: IgnoreWhitespaceType;
-  intraline_difference?: boolean;
   line_length?: number;
   manual_review?: boolean;
   retain_header?: boolean;
@@ -1790,10 +1690,8 @@
   show_tabs?: boolean;
   show_whitespace_errors?: boolean;
   skip_deleted?: boolean;
-  skip_uncommented?: boolean;
   syntax_highlighting?: boolean;
   hide_top_menu?: boolean;
-  auto_hide_diff_table_header?: boolean;
   hide_line_numbers?: boolean;
   tab_size?: number;
   font_size?: number;
@@ -1825,8 +1723,8 @@
   flushCaches?: boolean;
   killTask?: boolean;
   maintainServer?: boolean;
-  priority: UserPriority;
-  queryLimit: QueryLimitInfo;
+  priority?: UserPriority;
+  queryLimit?: QueryLimitInfo;
   runAs?: boolean;
   runGC?: boolean;
   streamEvents?: boolean;
@@ -2041,7 +1939,7 @@
  */
 export interface EditInfo {
   commit: CommitInfo;
-  base_patch_set_number: PatchSetNum;
+  base_patch_set_number: BasePatchSetNum;
   base_revision: string;
   ref: GitRef;
   fetch?: ProtocolToFetchInfoMap;
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
new file mode 100644
index 0000000..c3b38c6
--- /dev/null
+++ b/polygerrit-ui/app/types/diff.ts
@@ -0,0 +1,110 @@
+/**
+ * @fileoverview Types related to diffing.
+ *
+ * As gr-diff is an embeddable component, many of these types are actually
+ * defined in api/diff.ts. This file re-exports them and adds any
+ * internal fields that Gerrit may use.
+ *
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export {
+  ChangeType,
+  MoveDetails,
+  SkipLength,
+  MarkLength,
+  DiffIntralineInfo,
+  IgnoreWhitespaceType,
+} from '../api/diff';
+
+import {
+  DiffInfo as DiffInfoApi,
+  DiffFileMetaInfo as DiffFileMetaInfoApi,
+  DiffContent as DiffContentApi,
+  DiffPreferencesInfo as DiffPreferenceInfoApi,
+} from '../api/diff';
+
+export interface DiffInfo extends DiffInfoApi {
+  /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
+  meta_a: DiffFileMetaInfo;
+  /** Meta information about the file on side B as a DiffFileMetaInfo entity. */
+  meta_b: DiffFileMetaInfo;
+
+  /** A list of strings representing the patch set diff header. */
+  diff_header?: string[];
+
+  /**
+   * Links to the file diff in external sites as a list of DiffWebLinkInfo
+   * entries.
+   */
+  web_links?: DiffWebLinkInfo[];
+}
+
+/**
+ * The DiffWebLinkInfo entity describes a link on a diff screen to an external
+ * site.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-web-link-info
+ */
+export declare interface DiffWebLinkInfo {
+  /** The link name. */
+  name: string;
+  /** The link URL. */
+  url: string;
+  /** URL to the icon of the link. */
+  image_url: string;
+  // TODO: Are these really of type string? Not able to trigger them, but the
+  // docs sound more like boolean.
+  show_on_side_by_side_diff_view: string;
+  show_on_unified_diff_view: string;
+}
+
+export interface DiffFileMetaInfo extends DiffFileMetaInfoApi {
+  /** Links to the file in external sites as a list of WebLinkInfo entries. */
+  web_links?: WebLinkInfo[];
+}
+
+/**
+ * The WebLinkInfo entity describes a link to an external site.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#web-link-info
+ */
+export declare interface WebLinkInfo {
+  /** The link name. */
+  name: string;
+  /** The link URL. */
+  url: string;
+  /** URL to the icon of the link. */
+  image_url: string;
+}
+
+export interface DiffContent extends DiffContentApi {
+  // TODO: Undocumented, but used in code.
+  keyLocation?: boolean;
+}
+
+export interface DiffPreferencesInfo extends DiffPreferenceInfoApi {
+  expand_all_comments?: boolean;
+  cursor_blink_rate: number;
+  manual_review?: boolean;
+  retain_header?: boolean;
+  skip_deleted?: boolean;
+  hide_top_menu?: boolean;
+  hide_line_numbers?: boolean;
+  hide_empty_pane?: boolean;
+  match_brackets?: boolean;
+  line_wrapping?: boolean;
+}
+
+export declare type DiffPreferencesInfoKey = keyof DiffPreferencesInfo;
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 529904a..ff08c57 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -15,45 +15,89 @@
  * limitations under the License.
  */
 import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PatchSetNum} from './common';
+import {PatchSetNum, UrlEncodedCommentId} from './common';
 import {UIComment} from '../utils/comment-util';
+import {FetchRequest} from './types';
+import {MovedLinkClickedEventDetail} from '../api/diff';
+import {Category, RunStatus} from '../api/checks';
+import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 
-export interface TitleChangeEventDetail {
-  title: string;
+export enum EventType {
+  CHANGE_MESSAGE_DELETED = 'change-message-deleted',
+  DIALOG_CHANGE = 'dialog-change',
+  EDITABLE_CONTENT_SAVE = 'editable-content-save',
+  GR_RPC_LOG = 'gr-rpc-log',
+  LOCATION_CHANGE = 'location-change',
+  IRON_ANNOUNCE = 'iron-announce',
+  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',
+  RELOAD = 'reload',
+  REPLY = 'reply',
+  SERVER_ERROR = 'server-error',
+  SHORTCUT_TRIGGERERD = 'shortcut-triggered',
+  SHOW_ALERT = 'show-alert',
+  SHOW_ERROR = 'show-error',
+  SHOW_PRIMARY_TAB = 'show-primary-tab',
+  SHOW_SECONDARY_TAB = 'show-secondary-tab',
+  THREAD_LIST_MODIFIED = 'thread-list-modified',
+  TITLE_CHANGE = 'title-change',
 }
 
-export type TitleChangeEvent = CustomEvent<TitleChangeEventDetail>;
-
 declare global {
   interface HTMLElementEventMap {
+    'change-message-deleted': ChangeMessageDeletedEvent;
+    'dialog-change': DialogChangeEvent;
+    'editable-content-save': EditableContentSaveEvent;
+    'location-change': LocationChangeEvent;
+    'iron-announce': IronAnnounceEvent;
+    'moved-link-clicked': MovedLinkClickedEvent;
+    'open-fix-preview': OpenFixPreviewEvent;
+    'close-fix-preview': CloseFixPreviewEvent;
+    /* 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;
     'title-change': TitleChangeEvent;
   }
 }
 
-export interface PageErrorEventDetail {
-  response: Response;
-}
-
-export type PageErrorEvent = CustomEvent<PageErrorEventDetail>;
-
 declare global {
-  interface HTMLElementEventMap {
+  interface DocumentEventMap {
+    'gr-rpc-log': RpcLogEvent;
+    'network-error': NetworkErrorEvent;
     'page-error': PageErrorEvent;
+    'server-error': ServerErrorEvent;
+    'show-alert': ShowAlertEvent;
+    'show-error': ShowErrorEvent;
   }
 }
 
-export interface LocationChangeEventDetail {
-  hash: string;
-  pathname: string;
+export interface ChangeMessageDeletedEventDetail {
+  message: ChangeMessage;
 }
+export type ChangeMessageDeletedEvent = CustomEvent<ChangeMessageDeletedEventDetail>;
 
-export type LocationChangeEvent = CustomEvent<LocationChangeEventDetail>;
-
-declare global {
-  interface HTMLElementEventMap {
-    'location-change': LocationChangeEvent;
-  }
+// TODO(milutin) - remove once new gr-dialog will do it out of the box
+// This informs gr-app-element to remove footer, header from a11y tree
+export interface DialogChangeEventDetail {
+  canceled?: boolean;
+  opened?: boolean;
 }
+export type DialogChangeEvent = CustomEvent<DialogChangeEventDetail>;
+
+export interface EditableContentSaveEventDetail {
+  content: string;
+}
+export type EditableContentSaveEvent = CustomEvent<EditableContentSaveEventDetail>;
 
 export interface RpcLogEventDetail {
   status: number | null;
@@ -61,98 +105,115 @@
   elapsed: number;
   anonymizedUrl: string;
 }
-
 export type RpcLogEvent = CustomEvent<RpcLogEventDetail>;
 
-declare global {
-  interface HTMLElementEventMap {
-    'rpc-log': RpcLogEvent;
-  }
+export interface IronAnnounceEventDetail {
+  text: string;
 }
+export type IronAnnounceEvent = CustomEvent<IronAnnounceEventDetail>;
+
+export interface LocationChangeEventDetail {
+  hash: string;
+  pathname: string;
+}
+export type LocationChangeEvent = CustomEvent<LocationChangeEventDetail>;
+
+export type MovedLinkClickedEvent = CustomEvent<MovedLinkClickedEventDetail>;
+
+export interface NetworkErrorEventDetail {
+  error: Error;
+}
+export type NetworkErrorEvent = CustomEvent<NetworkErrorEventDetail>;
+
+export interface OpenFixPreviewEventDetail {
+  patchNum?: PatchSetNum;
+  comment?: UIComment;
+}
+export type OpenFixPreviewEvent = CustomEvent<OpenFixPreviewEventDetail>;
+
+export interface CloseFixPreviewEventDetail {
+  fixApplied: boolean;
+}
+export type CloseFixPreviewEvent = CustomEvent<CloseFixPreviewEventDetail>;
+
+export interface PageErrorEventDetail {
+  response?: Response;
+}
+export type PageErrorEvent = CustomEvent<PageErrorEventDetail>;
+
+export interface ReloadEventDetail {
+  clearPatchset: boolean;
+}
+export type ReloadEvent = CustomEvent<ReloadEventDetail>;
+
+export interface ReplyEventDetail {
+  message: ChangeMessage;
+}
+export type ReplyEvent = CustomEvent<ReplyEventDetail>;
+
+export interface ServerErrorEventDetail {
+  request?: FetchRequest;
+  response: Response;
+}
+export type ServerErrorEvent = CustomEvent<ServerErrorEventDetail>;
 
 export interface ShortcutTriggeredEventDetail {
   event: CustomKeyboardEvent;
   goKey: boolean;
   vKey: boolean;
 }
-
 export type ShortcutTriggeredEvent = CustomEvent<ShortcutTriggeredEventDetail>;
 
-declare global {
-  interface HTMLElementEventMap {
-    'shortcut-triggered': ShortcutTriggeredEvent;
-  }
+export interface ShowAlertEventDetail {
+  message: string;
+  dismissOnNavigation?: boolean;
+  showDismiss?: boolean;
+  action?: string;
+  callback?: () => void;
 }
+export type ShowAlertEvent = CustomEvent<ShowAlertEventDetail>;
 
-export interface EditableContentSaveEventDetail {
-  content: string;
+export interface ShowErrorEventDetail {
+  message: string;
 }
-
-export type EditableContentSaveEvent = CustomEvent<
-  EditableContentSaveEventDetail
->;
-
-declare global {
-  interface HTMLElementEventMap {
-    'editable-content-save': EditableContentSaveEvent;
-  }
-}
-
-export interface OpenFixPreviewEventDetail {
-  patchNum?: PatchSetNum;
-  comment?: UIComment;
-}
-
-export type OpenFixPreviewEvent = CustomEvent<OpenFixPreviewEventDetail>;
-
-declare global {
-  interface HTMLElementEventMap {
-    'open-fix-preview': OpenFixPreviewEvent;
-  }
-}
+export type ShowErrorEvent = CustomEvent<ShowErrorEventDetail>;
 
 // Type for the custom event to switch tab.
-interface SwitchTabEventDetail {
+export interface SwitchTabEventDetail {
   // name of the tab to set as active, from custom event
   tab?: string;
   // index of tab to set as active, from paper-tabs event
   value?: number;
   // scroll into the tab afterwards, from custom event
   scrollIntoView?: boolean;
+  // define state of tab after opening
+  tabState?: TabState;
 }
-
+export interface TabState {
+  commentTab?: CommentTabState;
+  checksTab?: ChecksTabState;
+}
+export enum CommentTabState {
+  UNRESOLVED = 'unresolved',
+  DRAFTS = 'drafts',
+  SHOW_ALL = 'show all',
+}
+export interface ChecksTabState {
+  statusOrCategory?: RunStatus | Category;
+  checkName?: string;
+}
 export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
 
-declare global {
-  interface HTMLElementEventMap {
-    'show-primary-tab': SwitchTabEvent;
-    'show-secondary-tab': SwitchTabEvent;
-  }
+export interface ThreadListModifiedDetail {
+  rootId: UrlEncodedCommentId;
+  path: string;
 }
+export type ThreadListModifiedEvent = CustomEvent<ThreadListModifiedDetail>;
 
-export interface ReloadEventDetail {
-  clearPatchset: boolean;
+export interface TitleChangeEventDetail {
+  title: string;
 }
-
-export type ReloadEvent = CustomEvent<ReloadEventDetail>;
-
-declare global {
-  interface HTMLElementEventMap {
-    reload: ReloadEvent;
-  }
-}
-
-export interface ShowAlertEventDetail {
-  message: string;
-}
-
-export type ShowAlertEvent = CustomEvent<ShowAlertEventDetail>;
-
-declare global {
-  interface HTMLElementEventMap {
-    'show-alert': ShowAlertEvent;
-  }
-}
+export type TitleChangeEvent = CustomEvent<TitleChangeEventDetail>;
 
 /**
  * Keyboard events emitted from polymer elements.
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 2962158..267ea12 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -23,12 +23,6 @@
   interface Window {
     CANONICAL_PATH?: string;
     INITIAL_DATA?: {[key: string]: ParsedJSON};
-    ShadyCSS?: {
-      getComputedStyleValue(el: Element, name: string): string;
-    };
-    ShadyDOM?: {
-      inUse?: boolean;
-    };
     HTMLImports?: {whenReady: (cb: () => void) => void};
     linkify(
       text: string,
@@ -38,20 +32,14 @@
     // TODO(TS): define gerrit type
     Gerrit?: {
       Nav?: unknown;
-      getRootElement?: unknown;
       Auth?: unknown;
-      _pluginLoader?: unknown;
-      _endpoints?: unknown;
-      slotToContent?: unknown;
-      rangesEqual?: unknown;
-      SUGGESTIONS_PROVIDERS_USERS_TYPES?: unknown;
-      RevisionInfo?: unknown;
-      CoverageType?: unknown;
-      hiddenscroll?: unknown;
-      flushPreinstalls?: () => void;
     };
     // TODO(TS): define polymer type
-    Polymer?: {importHref?: unknown};
+    Polymer: {
+      IronFocusablesHelper: {
+        getTabbableNodes: (el: Element) => Node[];
+      };
+    };
     // TODO(TS): remove page when better workaround is found
     // page shouldn't be exposed in window and it shouldn't be used
     // it's defined because of limitations from typescript, which don't import .mjs
@@ -75,51 +63,9 @@
     // TODO(TS): should clean up those and removing them may break certain plugin behaviors
     // TODO(TS): as @brohlfs suggested, to avoid importing anything from elements/ to types/
     // use any for them for now
-    GrDisplayNameUtils: unknown;
     GrAnnotation: unknown;
-    GrAttributeHelper: unknown;
-    GrDiffLine: unknown;
-    GrDiffLineType: unknown;
-    GrDiffGroup: unknown;
-    GrDiffGroupType: unknown;
-    GrDiffBuilder: unknown;
-    GrDiffBuilderSideBySide: unknown;
-    GrDiffBuilderImage: unknown;
-    GrDiffBuilderUnified: unknown;
-    GrDiffBuilderBinary: unknown;
-    GrChangeActionsInterface: unknown;
-    GrChangeReplyInterface: unknown;
-    GrEditConstants: unknown;
-    GrDomHooksManager: unknown;
-    GrDomHook: unknown;
-    GrEtagDecorator: unknown;
-    GrThemeApi: unknown;
-    SiteBasedCache: unknown;
-    FetchPromisesCache: unknown;
-    GrRestApiHelper: unknown;
-    GrLinkTextParser: unknown;
-    GrPluginEndpoints: unknown;
-    GrReviewerUpdatesParser: unknown;
-    GrPopupInterface: unknown;
-    GrCountStringFormatter: unknown;
-    GrReviewerSuggestionsProvider: unknown;
-    util: unknown;
-    Auth: unknown;
-    EventEmitter: unknown;
-    GrAdminApi: unknown;
-    GrAnnotationActionsContext: unknown;
-    GrAnnotationActionsInterface: unknown;
-    GrChangeMetadataApi: unknown;
-    GrEmailSuggestionsProvider: unknown;
-    GrGroupSuggestionsProvider: unknown;
-    GrEventHelper: unknown;
-    GrPluginRestApi: unknown;
-    GrRepoApi: unknown;
-    GrSettingsApi: unknown;
-    GrStylesApi: unknown;
-    PluginLoader: unknown;
+    // Heads up! There is a known plugin dependency on GrPluginActionContext.
     GrPluginActionContext: unknown;
-    _apiUtils: {};
   }
 
   interface Performance {
@@ -132,13 +78,6 @@
     };
   }
 
-  interface Event {
-    // path is a non-standard property. Actually, this is optional property,
-    // but marking it as optional breaks CustomKeyboardEvent
-    // TODO(TS): replace with composedPath if possible
-    readonly path: EventTarget[];
-  }
-
   interface Error {
     lineNumber?: number; // non-standard property
     columnNumber?: number; // non-standard property
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index b40d618..e3e1ada 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -14,19 +14,27 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {DiffViewMode, Side} from '../constants/constants';
+import {DiffLayer as DiffLayerApi} from '../api/diff';
+import {DiffViewMode, MessageTag, Side} from '../constants/constants';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import {GrDiffLine} from '../elements/diff/gr-diff/gr-diff-line';
 import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {
+  AccountInfo,
+  BasePatchSetNum,
   ChangeId,
+  ChangeViewChangeInfo,
   CommitId,
+  CommitInfo,
   NumericChangeId,
   PatchRange,
   PatchSetNum,
+  ReviewerUpdateInfo,
+  RevisionInfo,
+  Timestamp,
 } from './common';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+import {AuthRequestInit} from '../services/gr-auth/gr-auth';
 
 export function notUndefined<T>(x: T | undefined): x is T {
   return x !== undefined;
@@ -41,31 +49,7 @@
   commit: CommitId;
 }
 
-export interface CoverageRange {
-  type: CoverageType;
-  side: Side;
-  code_range: {end_line: number; start_line: number};
-}
-
-export enum CoverageType {
-  /**
-   * start_character and end_character of the range will be ignored for this
-   * type.
-   */
-  COVERED = 'COVERED',
-  /**
-   * start_character and end_character of the range will be ignored for this
-   * type.
-   */
-  NOT_COVERED = 'NOT_COVERED',
-  PARTIALLY_COVERED = 'PARTIALLY_COVERED',
-  /**
-   * You don't have to use this. If there is no coverage information for a
-   * range, then it implicitly means NOT_INSTRUMENTED. start_character and
-   * end_character of the range will be ignored for this type.
-   */
-  NOT_INSTRUMENTED = 'NOT_INSTRUMENTED',
-}
+export {CoverageRange, CoverageType} from '../api/diff';
 
 export enum ErrorType {
   AUTH = 'AUTH',
@@ -167,8 +151,7 @@
   side: Side
 ) => void;
 
-export interface DiffLayer {
-  annotate(el: HTMLElement, lineEl: HTMLElement, line: GrDiffLine): void;
+export interface DiffLayer extends DiffLayerApi {
   addListener?(listener: DiffLayerListener): void;
   removeListener?(listener: DiffLayerListener): void;
 }
@@ -201,7 +184,7 @@
 }
 
 export interface DashboardViewState {
-  selectedChangeIndex: number;
+  [key: string]: number;
 }
 
 export interface ViewState {
@@ -237,3 +220,30 @@
 >(x: T | PolymerSpliceChange<U>): x is PolymerSpliceChange<U> {
   return (x as PolymerSpliceChange<U>).indexSplices !== undefined;
 }
+
+export interface FetchRequest {
+  url: string;
+  fetchOptions?: AuthRequestInit;
+  anonymizedUrl?: string;
+}
+
+export interface FormattedReviewerUpdateInfo {
+  author: AccountInfo;
+  date: Timestamp;
+  type: 'REVIEWER_UPDATE';
+  tag: MessageTag.TAG_REVIEWER_UPDATE;
+  updates: {message: string; reviewers: AccountInfo[]}[];
+}
+
+export interface EditRevisionInfo extends Partial<RevisionInfo> {
+  // EditRevisionInfo has less required properties then RevisionInfo
+  _number: PatchSetNum;
+  basePatchNum: BasePatchSetNum;
+  commit: CommitInfo;
+}
+
+export interface ParsedChangeInfo
+  extends Omit<ChangeViewChangeInfo, 'reviewer_updates' | 'revisions'> {
+  revisions: {[revisionId: string]: RevisionInfo | EditRevisionInfo};
+  reviewer_updates?: ReviewerUpdateInfo[] | FormattedReviewerUpdateInfo[];
+}
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 7e425f8..bb9f328 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -28,6 +28,24 @@
   return !!account?.tags?.includes(AccountTag.SERVICE_USER);
 }
 
+export function isSelf(account?: AccountInfo, self?: AccountInfo): boolean {
+  return account?._account_id === self?._account_id;
+}
+
 export function removeServiceUsers(accounts?: AccountInfo[]): AccountInfo[] {
   return accounts?.filter(a => !isServiceUser(a)) || [];
 }
+
+export function hasSameAvatar(account?: AccountInfo, other?: AccountInfo) {
+  return account?.avatars?.[0]?.url === other?.avatars?.[0]?.url;
+}
+
+export function uniqueDefinedAvatar(
+  account: AccountInfo,
+  index: number,
+  accountArray: AccountInfo[]
+) {
+  return (
+    index === accountArray.findIndex(other => hasSameAvatar(account, other))
+  );
+}
diff --git a/polygerrit-ui/app/utils/admin-nav-util.ts b/polygerrit-ui/app/utils/admin-nav-util.ts
index 06f4e3a..275f9e6 100644
--- a/polygerrit-ui/app/utils/admin-nav-util.ts
+++ b/polygerrit-ui/app/utils/admin-nav-util.ts
@@ -16,7 +16,6 @@
  */
 import {
   GerritNav,
-  GerritView,
   RepoDetailView,
   GroupDetailView,
 } from '../elements/core/gr-navigation/gr-navigation';
@@ -26,8 +25,9 @@
   AccountDetailInfo,
   AccountCapabilityInfo,
 } from '../types/common';
-import {MenuLink} from '../elements/plugins/gr-admin-api/gr-admin-api';
 import {hasOwnProperty} from './common-util';
+import {GerritView} from '../services/router/router-model';
+import {MenuLink} from '../api/admin';
 
 const ADMIN_LINKS: NavLink[] = [
   {
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 119b09b..2b36fee 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -42,3 +42,71 @@
     return asyncForeach(array.slice(1), fn);
   });
 }
+
+export const _testOnly_allTasks = new Map<number, DelayedTask>();
+
+/**
+ * This is just a very simple and small wrapper around setTimeout(). Instead of
+ * the usual:
+ *
+ * const timer = window.setTimeout(() => {...do stuff...}, 123);
+ * window.clearTimeout(timer);
+ *
+ * With this class you can do:
+ *
+ * const task = new Task(() => {...do stuff...}, 123);
+ * task.cancel();
+ *
+ * It is just nicer to have an object for this instead of a number as a handle.
+ */
+export class DelayedTask {
+  private timer?: number;
+
+  constructor(private callback: () => void, waitMs = 0) {
+    this.timer = window.setTimeout(() => {
+      if (this.timer) _testOnly_allTasks.delete(this.timer);
+      this.timer = undefined;
+      if (this.callback) this.callback();
+    }, waitMs);
+    _testOnly_allTasks.set(this.timer, this);
+  }
+
+  cancel() {
+    if (this.isActive()) {
+      window.clearTimeout(this.timer);
+      if (this.timer) _testOnly_allTasks.delete(this.timer);
+      this.timer = undefined;
+    }
+  }
+
+  flush() {
+    if (this.isActive()) {
+      this.cancel();
+      if (this.callback) this.callback();
+    }
+  }
+
+  isActive() {
+    return this.timer !== undefined;
+  }
+}
+
+/**
+ * The usage pattern is:
+ *
+ * this.myDebouncedTask = debounce(this.myDebouncedTask, () => {...}, 123);
+ *
+ * It is identical to:
+ *
+ * this.myTask = new DelayedTask(() => {...}, 123);
+ *
+ * But it would cancel a potentially scheduled task beforehand.
+ */
+export function debounce(
+  existingTask: DelayedTask | undefined,
+  callback: () => void,
+  waitMs = 0
+) {
+  existingTask?.cancel();
+  return new DelayedTask(callback, waitMs);
+}
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
index b0aefcb..5b2762c 100644
--- a/polygerrit-ui/app/utils/attention-set-util.ts
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -17,6 +17,7 @@
 
 import {AccountInfo, ChangeInfo} from '../types/common';
 import {isServiceUser} from './account-util';
+import {hasOwnProperty} from './common-util';
 
 // You would typically use a ServerInfo here, but this utility does not care
 // about all the other parameters in that object.
@@ -46,7 +47,8 @@
   return (
     isAttentionSetEnabled(config) &&
     canHaveAttention(account) &&
-    !!change?.attention_set?.hasOwnProperty(account!._account_id!)
+    !!change?.attention_set &&
+    hasOwnProperty(change?.attention_set, account!._account_id!)
   );
 }
 
diff --git a/polygerrit-ui/app/utils/change-metadata-util.ts b/polygerrit-ui/app/utils/change-metadata-util.ts
new file mode 100644
index 0000000..eda988a
--- /dev/null
+++ b/polygerrit-ui/app/utils/change-metadata-util.ts
@@ -0,0 +1,81 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {ParsedChangeInfo} from '../types/types';
+
+export enum Metadata {
+  OWNER = 'Owner',
+  REVIEWERS = 'Reviewers',
+  REPO_BRANCH = 'Repo | Branch',
+  SUBMITTED = 'Submitted',
+  PARENT = 'Parent',
+  MERGED_AS = 'Merged as',
+  STRATEGY = 'Strategy',
+  UPDATED = 'Updated',
+  CC = 'CC',
+  HASHTAGS = 'Hashtags',
+  TOPIC = 'Topic',
+  UPLOADER = 'Uploader',
+  AUTHOR = 'Author',
+  COMMITTER = 'Committer',
+  ASSIGNEE = 'Assignee',
+  CHERRY_PICK_OF = 'Cherry pick of',
+}
+
+export const DisplayRules = {
+  ALWAYS_SHOW: [
+    Metadata.OWNER,
+    Metadata.REVIEWERS,
+    Metadata.REPO_BRANCH,
+    Metadata.SUBMITTED,
+    Metadata.TOPIC,
+  ],
+  SHOW_IF_SET: [
+    Metadata.CC,
+    Metadata.HASHTAGS,
+    Metadata.UPLOADER,
+    Metadata.AUTHOR,
+    Metadata.COMMITTER,
+    Metadata.ASSIGNEE,
+    Metadata.CHERRY_PICK_OF,
+  ],
+  ALWAYS_HIDE: [
+    Metadata.PARENT,
+    Metadata.MERGED_AS,
+    Metadata.STRATEGY,
+    Metadata.UPDATED,
+  ],
+};
+
+export function isSectionSet(section: Metadata, change?: ParsedChangeInfo) {
+  switch (section) {
+    case Metadata.CC:
+      return !!change?.reviewers?.CC?.length;
+    case Metadata.HASHTAGS:
+      return !!change?.hashtags?.length;
+    case Metadata.TOPIC:
+      return !!change?.topic;
+    case Metadata.UPLOADER:
+    case Metadata.AUTHOR:
+    case Metadata.COMMITTER:
+    case Metadata.ASSIGNEE:
+      return false;
+    case Metadata.CHERRY_PICK_OF:
+      return !!change?.cherry_pick_of_change;
+  }
+  return true;
+}
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 4c0bdef..3426dec 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -21,8 +21,9 @@
   PatchSetNum,
   ChangeInfo,
   AccountInfo,
+  RelatedChangeAndCommitInfo,
 } from '../types/common';
-import {ParsedChangeInfo} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {ParsedChangeInfo} from '../types/types';
 
 // This can be wrong! See WARNING above
 interface ChangeStatusesOptions {
@@ -182,13 +183,58 @@
   return states;
 }
 
-export function isOwner(change?: ChangeInfo, account?: AccountInfo) {
+export function isOwner(change?: ChangeInfo, account?: AccountInfo): boolean {
   if (!change || !account) return false;
   return change.owner?._account_id === account._account_id;
 }
 
-export function changeStatusString(change: ChangeInfo) {
-  return changeStatuses(change).join(', ');
+export function isReviewer(
+  change?: ChangeInfo,
+  account?: AccountInfo
+): boolean {
+  if (!change || !account) return false;
+  const reviewers = change.reviewers.REVIEWER ?? [];
+  return reviewers.some(r => r._account_id === account._account_id);
+}
+
+export function isCc(change?: ChangeInfo, account?: AccountInfo): boolean {
+  if (!change || !account) return false;
+  const ccs = change.reviewers.CC ?? [];
+  return ccs.some(r => r._account_id === account._account_id);
+}
+
+export function isUploader(
+  change?: ChangeInfo,
+  account?: AccountInfo
+): boolean {
+  if (!change || !account) return false;
+  const rev = getCurrentRevision(change);
+  return rev?.uploader?._account_id === account._account_id;
+}
+
+export function isInvolved(
+  change?: ChangeInfo,
+  account?: AccountInfo
+): boolean {
+  const owner = isOwner(change, account);
+  const uploader = isUploader(change, account);
+  const reviewer = isReviewer(change, account);
+  const cc = isCc(change, account);
+  return owner || uploader || reviewer || cc;
+}
+
+export function getCurrentRevision(change?: ChangeInfo | ParsedChangeInfo) {
+  if (!change?.revisions || !change?.current_revision) return undefined;
+  return change.revisions[change.current_revision];
+}
+
+export function getRevisionKey(
+  change: ChangeInfo | ParsedChangeInfo,
+  patchNum: PatchSetNum
+) {
+  return Object.keys(change.revisions ?? []).find(
+    rev => change?.revisions?.[rev]._number === patchNum
+  );
 }
 
 export function isRemovableReviewer(
@@ -202,3 +248,9 @@
       (!reviewer._account_id && account.email === reviewer.email)
   );
 }
+
+export function isChangeInfo(
+  x: ChangeInfo | RelatedChangeAndCommitInfo | ParsedChangeInfo
+): x is ChangeInfo | ParsedChangeInfo {
+  return (x as ChangeInfo)._number !== undefined;
+}
diff --git a/polygerrit-ui/app/utils/change-util_test.js b/polygerrit-ui/app/utils/change-util_test.js
index 97884af..d62a5ff 100644
--- a/polygerrit-ui/app/utils/change-util_test.js
+++ b/polygerrit-ui/app/utils/change-util_test.js
@@ -23,7 +23,6 @@
   changeIsAbandoned,
   changePath,
   changeStatuses,
-  changeStatusString,
   isRemovableReviewer,
 } from './change-util.js';
 
@@ -62,9 +61,7 @@
       mergeable: true,
     };
     let statuses = changeStatuses(change);
-    const statusString = changeStatusString(change);
     assert.deepEqual(statuses, []);
-    assert.equal(statusString, '');
 
     change.submittable = false;
     statuses = changeStatuses(change,
@@ -112,9 +109,7 @@
       mergeable: false,
     };
     const statuses = changeStatuses(change);
-    const statusString = changeStatusString(change);
     assert.deepEqual(statuses, ['Merge Conflict']);
-    assert.equal(statusString, 'Merge Conflict');
   });
 
   test('mergeable prop undefined', () => {
@@ -128,9 +123,7 @@
       labels: {},
     };
     const statuses = changeStatuses(change);
-    const statusString = changeStatusString(change);
     assert.deepEqual(statuses, []);
-    assert.equal(statusString, '');
   });
 
   test('Merged status', () => {
@@ -144,9 +137,7 @@
       labels: {},
     };
     const statuses = changeStatuses(change);
-    const statusString = changeStatusString(change);
     assert.deepEqual(statuses, ['Merged']);
-    assert.equal(statusString, 'Merged');
   });
 
   test('Abandoned status', () => {
@@ -160,9 +151,7 @@
       labels: {},
     };
     const statuses = changeStatuses(change);
-    const statusString = changeStatusString(change);
     assert.deepEqual(statuses, ['Abandoned']);
-    assert.equal(statusString, 'Abandoned');
   });
 
   test('Open status with private and wip', () => {
@@ -179,9 +168,7 @@
       mergeable: true,
     };
     const statuses = changeStatuses(change);
-    const statusString = changeStatusString(change);
     assert.deepEqual(statuses, ['WIP', 'Private']);
-    assert.equal(statusString, 'WIP, Private');
   });
 
   test('Merge conflict with private and wip', () => {
@@ -198,9 +185,7 @@
       mergeable: false,
     };
     const statuses = changeStatuses(change);
-    const statusString = changeStatusString(change);
     assert.deepEqual(statuses, ['Merge Conflict', 'WIP', 'Private']);
-    assert.equal(statusString, 'Merge Conflict, WIP, Private');
   });
 
   test('isRemovableReviewer', () => {
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 5f8aa82..2d5f66e 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -21,9 +21,19 @@
   RobotCommentInfo,
   Timestamp,
   UrlEncodedCommentId,
+  CommentRange,
+  PatchRange,
+  ParentPatchSetNum,
+  ContextLine,
+  BasePatchSetNum,
+  RevisionPatchSetNum,
 } from '../types/common';
-import {CommentSide, Side} from '../constants/constants';
+import {CommentSide, Side, SpecialFilePath} from '../constants/constants';
 import {parseDate} from './date-util';
+import {LineNumber} from '../elements/diff/gr-diff/gr-diff-line';
+import {CommentIdToCommentThreadMap} from '../elements/diff/gr-comment-api/gr-comment-api';
+import {isMergeParent, getParentIndex} from './patch-set-util';
+import {DiffInfo} from '../types/diff';
 
 export interface DraftCommentProps {
   __draft?: boolean;
@@ -39,19 +49,14 @@
 export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
 
 export interface UIStateCommentProps {
-  // The `side` of the comment is PARENT or REVISION, but this is LEFT or RIGHT.
-  // TODO(TS): Remove the naming confusion of commentSide being of type of Side,
-  // but side being of type CommentSide. :-)
-  __commentSide?: Side;
-  // TODO(TS): Remove this. Seems to be exactly the same as `path`??
-  __path?: string;
   collapsed?: boolean;
-  // TODO(TS): Consider allowing this only for drafts.
-  __editing?: boolean;
-  __otherEditing?: boolean;
 }
 
-export type UIDraft = DraftInfo & UIStateCommentProps;
+export interface UIStateDraftProps {
+  __editing?: boolean;
+}
+
+export type UIDraft = DraftInfo & UIStateCommentProps & UIStateDraftProps;
 
 export type UIHuman = CommentInfo & UIStateCommentProps;
 
@@ -97,17 +102,74 @@
   });
 }
 
+export function createCommentThreads(
+  comments: UIComment[],
+  patchRange?: PatchRange
+) {
+  const sortedComments = sortComments(comments);
+  const threads: CommentThread[] = [];
+  const idThreadMap: CommentIdToCommentThreadMap = {};
+  for (const comment of sortedComments) {
+    if (!comment.id) continue;
+    // If the comment is in reply to another comment, find that comment's
+    // thread and append to it.
+    if (comment.in_reply_to) {
+      const thread = idThreadMap[comment.in_reply_to];
+      if (thread) {
+        thread.comments.push(comment);
+        idThreadMap[comment.id] = thread;
+        continue;
+      }
+    }
+
+    // Otherwise, this comment starts its own thread.
+    if (!comment.path) {
+      throw new Error('Comment missing required "path".');
+    }
+    const newThread: CommentThread = {
+      comments: [comment],
+      patchNum: comment.patch_set,
+      commentSide: comment.side ?? CommentSide.REVISION,
+      mergeParentNum: comment.parent,
+      path: comment.path,
+      line: comment.line,
+      range: comment.range,
+      rootId: comment.id,
+    };
+    if (patchRange) {
+      if (isInBaseOfPatchRange(comment, patchRange))
+        newThread.diffSide = Side.LEFT;
+      else if (isInRevisionOfPatchRange(comment, patchRange))
+        newThread.diffSide = Side.RIGHT;
+      else throw new Error('comment does not belong in given patchrange');
+    }
+    if (!comment.line && !comment.range) {
+      newThread.line = 'FILE';
+    }
+    threads.push(newThread);
+    idThreadMap[comment.id] = newThread;
+  }
+  return threads;
+}
+
 export interface CommentThread {
   comments: UIComment[];
-  patchNum?: PatchSetNum;
   path: string;
-  // TODO(TS): It would be nice to use LineNumber here, but the comment thread
-  // element actually relies on line to be undefined for file comments. Be
-  // aware of element attribute getters and setters, if you try to refactor
-  // this. :-) Still worthwhile to do ...
-  line?: number;
-  rootId: UrlEncodedCommentId;
-  commentSide?: CommentSide;
+  commentSide: CommentSide;
+  /* mergeParentNum is the merge parent number only valid for merge commits
+     when commentSide is PARENT.
+     mergeParentNum is undefined for auto merge commits
+  */
+  mergeParentNum?: number;
+  patchNum?: PatchSetNum;
+  line?: LineNumber;
+  /* rootId is optional since we create a empty comment thread element for
+     drafts and then create the draft which becomes the root */
+  rootId?: UrlEncodedCommentId;
+  diffSide?: Side;
+  range?: CommentRange;
+  ported?: boolean; // is the comment ported over from a previous patchset
+  rangeInfoLost?: boolean; // if BE was unable to determine a range for this
 }
 
 export function getLastComment(thread?: CommentThread): UIComment | undefined {
@@ -115,10 +177,156 @@
   return thread && len ? thread.comments[len - 1] : undefined;
 }
 
+export function getFirstComment(thread?: CommentThread): UIComment | undefined {
+  return thread?.comments?.[0];
+}
+
+export function countComments(thread?: CommentThread) {
+  return thread?.comments?.length ?? 0;
+}
+
+export function isPatchsetLevel(thread?: CommentThread): boolean {
+  return thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+}
+
 export function isUnresolved(thread?: CommentThread): boolean {
-  return !!getLastComment(thread)?.unresolved;
+  return !isResolved(thread);
+}
+
+export function isResolved(thread?: CommentThread): boolean {
+  return !getLastComment(thread)?.unresolved;
 }
 
 export function isDraftThread(thread?: CommentThread): boolean {
   return isDraft(getLastComment(thread));
 }
+
+export function isRobotThread(thread?: CommentThread): boolean {
+  return isRobot(getFirstComment(thread));
+}
+
+export function hasHumanReply(thread?: CommentThread): boolean {
+  return countComments(thread) > 1 && !isRobot(getLastComment(thread));
+}
+
+/**
+ * Whether the given comment should be included in the base side of the
+ * given patch range.
+ */
+export function isInBaseOfPatchRange(
+  comment: CommentBasics,
+  range: PatchRange
+) {
+  // If the base of the patch range is a parent of a merge, and the comment
+  // appears on a specific parent then only show the comment if the parent
+  // index of the comment matches that of the range.
+  if (comment.parent && comment.side === CommentSide.PARENT) {
+    return (
+      isMergeParent(range.basePatchNum) &&
+      comment.parent === getParentIndex(range.basePatchNum)
+    );
+  }
+
+  // If the base of the range is the parent of the patch:
+  if (
+    range.basePatchNum === ParentPatchSetNum &&
+    comment.side === CommentSide.PARENT &&
+    comment.patch_set === range.patchNum
+  ) {
+    return true;
+  }
+  // If the base of the range is not the parent of the patch:
+  return (
+    range.basePatchNum !== ParentPatchSetNum &&
+    comment.side !== CommentSide.PARENT &&
+    comment.patch_set === range.basePatchNum
+  );
+}
+
+/**
+ * Whether the given comment should be included in the revision side of the
+ * given patch range.
+ */
+export function isInRevisionOfPatchRange(
+  comment: CommentBasics,
+  range: PatchRange
+) {
+  return (
+    comment.side !== CommentSide.PARENT && comment.patch_set === range.patchNum
+  );
+}
+
+/**
+ * Whether the given comment should be included in the given patch range.
+ */
+export function isInPatchRange(
+  comment: CommentBasics,
+  range: PatchRange
+): boolean {
+  return (
+    isInBaseOfPatchRange(comment, range) ||
+    isInRevisionOfPatchRange(comment, range)
+  );
+}
+
+export function getPatchRangeForCommentUrl(
+  comment: UIComment,
+  latestPatchNum: RevisionPatchSetNum
+) {
+  if (!comment.patch_set) throw new Error('Missing comment.patch_set');
+
+  // TODO(dhruvsri): Add handling for comment left on parents of merge commits
+  if (comment.side === CommentSide.PARENT) {
+    if (comment.patch_set === ParentPatchSetNum)
+      throw new Error('diffSide cannot be PARENT');
+    return {
+      patchNum: comment.patch_set as RevisionPatchSetNum,
+      basePatchNum: ParentPatchSetNum,
+    };
+  } else if (latestPatchNum === comment.patch_set) {
+    return {
+      patchNum: latestPatchNum,
+      basePatchNum: ParentPatchSetNum,
+    };
+  } else {
+    return {
+      patchNum: latestPatchNum as RevisionPatchSetNum,
+      basePatchNum: comment.patch_set as BasePatchSetNum,
+    };
+  }
+}
+
+export function computeDiffFromContext(
+  context: ContextLine[],
+  path: string,
+  content_type?: string
+) {
+  // do not render more than 20 lines of context
+  context = context.slice(0, 20);
+  const diff: DiffInfo = {
+    meta_a: {
+      name: '',
+      content_type: '',
+      lines: 0,
+      web_links: [],
+    },
+    meta_b: {
+      name: path,
+      content_type: content_type || '',
+      lines: context.length + context?.[0].line_number,
+      web_links: [],
+    },
+    change_type: 'MODIFIED',
+    intraline_status: 'OK',
+    diff_header: [],
+    content: [
+      {
+        skip: context[0].line_number - 1,
+      },
+      {
+        b: context.map(line => line.context_line),
+      },
+    ],
+  };
+  return diff;
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.js b/polygerrit-ui/app/utils/comment-util_test.js
deleted file mode 100644
index ad19974..0000000
--- a/polygerrit-ui/app/utils/comment-util_test.js
+++ /dev/null
@@ -1,34 +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 {
-  isUnresolved,
-} from './comment-util.js';
-
-suite('comment-util', () => {
-  test('isUnresolved', () => {
-    assert.isFalse(isUnresolved(undefined));
-    assert.isFalse(isUnresolved({comments: []}));
-    assert.isTrue(isUnresolved({comments: [{unresolved: true}]}));
-    assert.isFalse(isUnresolved({comments: [{unresolved: false}]}));
-    assert.isTrue(isUnresolved(
-        {comments: [{unresolved: false}, {unresolved: true}]}));
-    assert.isFalse(isUnresolved(
-        {comments: [{unresolved: true}, {unresolved: false}]}));
-  });
-});
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
new file mode 100644
index 0000000..3c8f26d
--- /dev/null
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -0,0 +1,258 @@
+/**
+ * @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 {
+  isUnresolved,
+  getPatchRangeForCommentUrl,
+  createCommentThreads,
+  sortComments,
+} from './comment-util';
+import {createComment, createCommentThread} from '../test/test-data-generators';
+import {CommentSide, Side} from '../constants/constants';
+import {
+  BasePatchSetNum,
+  ParentPatchSetNum,
+  PatchSetNum,
+  RevisionPatchSetNum,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../types/common';
+
+suite('comment-util', () => {
+  test('isUnresolved', () => {
+    const thread = createCommentThread([createComment()]);
+
+    assert.isFalse(isUnresolved(undefined));
+    assert.isFalse(isUnresolved(thread));
+
+    assert.isTrue(
+      isUnresolved({
+        ...thread,
+        comments: [{...createComment(), unresolved: true}],
+      })
+    );
+    assert.isFalse(
+      isUnresolved({
+        ...thread,
+        comments: [{...createComment(), unresolved: false}],
+      })
+    );
+    assert.isTrue(
+      isUnresolved({
+        ...thread,
+        comments: [
+          {...createComment(), unresolved: false},
+          {...createComment(), unresolved: true},
+        ],
+      })
+    );
+    assert.isFalse(
+      isUnresolved({
+        ...thread,
+        comments: [
+          {...createComment(), unresolved: true},
+          {...createComment(), unresolved: false},
+        ],
+      })
+    );
+  });
+
+  test('getPatchRangeForCommentUrl', () => {
+    test('comment created with side=PARENT does not navigate to latest ps', () => {
+      const comment = {
+        ...createComment(),
+        id: 'c4' as UrlEncodedCommentId,
+        line: 10,
+        patch_set: 4 as PatchSetNum,
+        side: CommentSide.PARENT,
+        path: '/COMMIT_MSG',
+      };
+      assert.deepEqual(
+        getPatchRangeForCommentUrl(comment, 11 as RevisionPatchSetNum),
+        {
+          basePatchNum: ParentPatchSetNum,
+          patchNum: 4 as PatchSetNum,
+        }
+      );
+    });
+  });
+
+  test('comments sorting', () => {
+    const comments = [
+      {
+        id: 'new_draft' as UrlEncodedCommentId,
+        message: 'i do not like either of you',
+        diffSide: Side.LEFT,
+        __draft: true,
+        updated: '2015-12-20 15:01:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sallys_confession' as UrlEncodedCommentId,
+        message: 'i like you, jack',
+        updated: '2015-12-23 15:00:20.396000000' as Timestamp,
+        line: 1,
+        diffSide: Side.LEFT,
+      },
+      {
+        id: 'jacks_reply' as UrlEncodedCommentId,
+        message: 'i like you, too',
+        updated: '2015-12-24 15:01:20.396000000' as Timestamp,
+        diffSide: Side.LEFT,
+        line: 1,
+        in_reply_to: 'sallys_confession',
+      },
+    ];
+    const sortedComments = sortComments(comments);
+    assert.equal(sortedComments[0], comments[1]);
+    assert.equal(sortedComments[1], comments[2]);
+    assert.equal(sortedComments[2], comments[0]);
+  });
+
+  suite('createCommentThreads', () => {
+    test('creates threads from individual comments', () => {
+      const comments = [
+        {
+          id: 'sallys_confession' as UrlEncodedCommentId,
+          message: 'i like you, jack',
+          updated: '2015-12-23 15:00:20.396000000' as Timestamp,
+          line: 1,
+          patch_set: 1 as PatchSetNum,
+          path: 'some/path',
+        },
+        {
+          id: 'jacks_reply' as UrlEncodedCommentId,
+          message: 'i like you, too',
+          updated: '2015-12-24 15:01:20.396000000' as Timestamp,
+          line: 1,
+          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+          patch_set: 1 as PatchSetNum,
+          path: 'some/path',
+        },
+        {
+          id: 'new_draft' as UrlEncodedCommentId,
+          message: 'i do not like either of you' as UrlEncodedCommentId,
+          __draft: true,
+          updated: '2015-12-20 15:01:20.396000000' as Timestamp,
+          patch_set: 1 as PatchSetNum,
+          path: 'some/path',
+        },
+      ];
+
+      const actualThreads = createCommentThreads(comments, {
+        basePatchNum: 1 as BasePatchSetNum,
+        patchNum: 4 as RevisionPatchSetNum,
+      });
+
+      assert.equal(actualThreads.length, 2);
+
+      assert.equal(actualThreads[0].diffSide, Side.LEFT);
+      assert.equal(actualThreads[0].comments.length, 2);
+      assert.deepEqual(actualThreads[0].comments[0], comments[0]);
+      assert.deepEqual(actualThreads[0].comments[1], comments[1]);
+      assert.equal(actualThreads[0].patchNum, 1 as PatchSetNum);
+      assert.equal(actualThreads[0].line, 1);
+
+      assert.equal(actualThreads[1].diffSide, Side.LEFT);
+      assert.equal(actualThreads[1].comments.length, 1);
+      assert.deepEqual(actualThreads[1].comments[0], comments[2]);
+      assert.equal(actualThreads[1].patchNum, 1 as PatchSetNum);
+      assert.equal(actualThreads[1].line, 'FILE');
+    });
+
+    test('derives patchNum and range', () => {
+      const comments = [
+        {
+          id: 'betsys_confession' as UrlEncodedCommentId,
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:10.396000000' as Timestamp,
+          range: {
+            start_line: 1,
+            start_character: 1,
+            end_line: 1,
+            end_character: 2,
+          },
+          patch_set: 5 as PatchSetNum,
+          path: '/p',
+          line: 1,
+        },
+      ];
+
+      const expectedThreads = [
+        {
+          diffSide: Side.LEFT,
+          commentSide: CommentSide.REVISION,
+          path: '/p',
+          rootId: 'betsys_confession' as UrlEncodedCommentId,
+          mergeParentNum: undefined,
+          comments: [
+            {
+              id: 'betsys_confession' as UrlEncodedCommentId,
+              path: '/p',
+              message: 'i like you, jack',
+              updated: '2015-12-24 15:00:10.396000000' as Timestamp,
+              range: {
+                start_line: 1,
+                start_character: 1,
+                end_line: 1,
+                end_character: 2,
+              },
+              patch_set: 5 as PatchSetNum,
+              line: 1,
+            },
+          ],
+          patchNum: 5 as PatchSetNum,
+          range: {
+            start_line: 1,
+            start_character: 1,
+            end_line: 1,
+            end_character: 2,
+          },
+          line: 1,
+        },
+      ];
+
+      assert.deepEqual(
+        createCommentThreads(comments, {
+          basePatchNum: 5 as BasePatchSetNum,
+          patchNum: 10 as RevisionPatchSetNum,
+        }),
+        expectedThreads
+      );
+    });
+
+    test('does not thread unrelated comments at same location', () => {
+      const comments = [
+        {
+          id: 'sallys_confession' as UrlEncodedCommentId,
+          message: 'i like you, jack',
+          updated: '2015-12-23 15:00:20.396000000' as Timestamp,
+          diffSide: Side.LEFT,
+          path: '/p',
+        },
+        {
+          id: 'jacks_reply' as UrlEncodedCommentId,
+          message: 'i like you, too',
+          updated: '2015-12-24 15:01:20.396000000' as Timestamp,
+          diffSide: Side.LEFT,
+          path: '/p',
+        },
+      ];
+      assert.equal(createCommentThreads(comments).length, 2);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 5b332ea..08b5e49 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -47,6 +47,50 @@
 }
 
 /**
+ * Throws an error with the provided error message if the condition is false.
+ */
+export function check(
+  condition: boolean,
+  errorMessage: string
+): asserts condition {
+  if (!condition) throw new Error(errorMessage);
+}
+
+/**
+ * Throws an error if the property is not defined.
+ */
+export function checkProperty(
+  condition: boolean,
+  propertyName: string
+): asserts condition {
+  check(condition, `missing required property '${propertyName}'`);
+}
+
+/**
+ * Throws an error if the property is not defined.
+ */
+export function checkRequiredProperty<T>(
+  property: T,
+  propertyName: string
+): asserts property is NonNullable<T> {
+  if (property === undefined || property === null) {
+    throw new Error(`Required property '${propertyName}' not set.`);
+  }
+}
+
+/**
+ * Throws an error if the property is not defined.
+ */
+export function assertIsDefined<T>(
+  val: T,
+  variableName = 'variable'
+): asserts val is NonNullable<T> {
+  if (val === undefined || val === null) {
+    throw new Error(`${variableName} is not defined`);
+  }
+}
+
+/**
  * Returns true, if both sets contain the same members.
  */
 export function areSetsEqual<T>(a: Set<T>, b: Set<T>): boolean {
@@ -67,3 +111,18 @@
   }
   return true;
 }
+
+/**
+ * Add value, if the set does not contain it. Otherwise remove it.
+ */
+export function toggleSetMembership<T>(set: Set<T>, value: T): void {
+  if (set.has(value)) {
+    set.delete(value);
+  } else {
+    set.add(value);
+  }
+}
+
+export function unique<T>(item: T, index: number, array: T[]) {
+  return array.indexOf(item) === index;
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.js b/polygerrit-ui/app/utils/common-util_test.js
index 917d652b..d6d66d7 100644
--- a/polygerrit-ui/app/utils/common-util_test.js
+++ b/polygerrit-ui/app/utils/common-util_test.js
@@ -29,7 +29,7 @@
       assert.isTrue(hasOwnProperty(obj, 'name with spaces'));
       assert.isFalse(hasOwnProperty(obj, 'def'));
     });
-    test('object prototype has overriden hasOwnProperty', () => {
+    test('object prototype has overridden hasOwnProperty', () => {
       const F = function() {
         this.abc = 23;
       };
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index 1dd2d2f..a780af5 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -37,27 +37,31 @@
 
 // similar to fromNow from moment.js
 export function fromNow(date: Date, noAgo = false) {
-  const now = new Date();
+  return durationString(date, new Date(), noAgo);
+}
+
+// similar to fromNow from moment.js
+export function durationString(from: Date, to: Date, noAgo = false) {
   const ago = noAgo ? '' : ' ago';
-  const secondsAgo = Math.round((now.valueOf() - date.valueOf()) / 1000);
+  const secondsAgo = Math.floor((to.valueOf() - from.valueOf()) / 1000);
   if (secondsAgo <= 59) return 'just now';
   if (secondsAgo <= 119) return `1 minute${ago}`;
-  const minutesAgo = Math.round(secondsAgo / 60);
+  const minutesAgo = Math.floor(secondsAgo / 60);
   if (minutesAgo <= 59) return `${minutesAgo} minutes${ago}`;
   if (minutesAgo === 60) return `1 hour${ago}`;
   if (minutesAgo <= 119) return `1 hour ${minutesAgo - 60} min${ago}`;
-  const hoursAgo = Math.round(minutesAgo / 60);
+  const hoursAgo = Math.floor(minutesAgo / 60);
   if (hoursAgo <= 23) return `${hoursAgo} hours${ago}`;
   if (hoursAgo === 24) return `1 day${ago}`;
   if (hoursAgo <= 47) return `1 day ${hoursAgo - 24} hr${ago}`;
-  const daysAgo = Math.round(hoursAgo / 24);
+  const daysAgo = Math.floor(hoursAgo / 24);
   if (daysAgo <= 30) return `${daysAgo} days${ago}`;
   if (daysAgo <= 60) return `1 month${ago}`;
-  const monthsAgo = Math.round(daysAgo / 30);
+  const monthsAgo = Math.floor(daysAgo / 30);
   if (monthsAgo <= 11) return `${monthsAgo} months${ago}`;
   if (monthsAgo === 12) return `1 year${ago}`;
   if (monthsAgo <= 24) return `1 year ${monthsAgo - 12} m${ago}`;
-  const yearsAgo = Math.round(daysAgo / 365);
+  const yearsAgo = Math.floor(daysAgo / 365);
   return `${yearsAgo} years${ago}`;
 }
 
@@ -69,6 +73,16 @@
   return diff < Duration.DAY && date.getDay() === now.getDay();
 }
 
+export function wasYesterday(now: Date, date: Date) {
+  const diff = now.valueOf() - date.valueOf();
+  // return true if date is withing 24 hours and not on the same day
+  if (diff < Duration.DAY && date.getDay() !== now.getDay()) return true;
+
+  // move now to yesterday
+  now.setDate(now.getDate() - 1);
+  return isWithinDay(now, date);
+}
+
 /**
  * Returns true if date is from one to six months.
  */
@@ -76,15 +90,6 @@
   const diff = now.valueOf() - date.valueOf();
   return diff < 180 * Duration.DAY;
 }
-interface Options {
-  month?: string;
-  year?: string;
-  day?: string;
-  hour?: string;
-  hour12?: boolean;
-  minute?: string;
-  second?: string;
-}
 
 // TODO(dmfilippov): TS-Fix review this type. All fields here must be optional,
 // but this require some changes in the code. During JS->TS migration
@@ -104,7 +109,7 @@
 }
 
 export function formatDate(date: Date, format: string) {
-  const options: Options = {};
+  const options: Intl.DateTimeFormatOptions = {};
   if (format.includes('MM')) {
     if (format.includes('MMM')) {
       options.month = 'short';
diff --git a/polygerrit-ui/app/utils/date-util_test.js b/polygerrit-ui/app/utils/date-util_test.js
index a003c65..96d5bc1 100644
--- a/polygerrit-ui/app/utils/date-util_test.js
+++ b/polygerrit-ui/app/utils/date-util_test.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import '../test/common-test-setup-karma.js';
-import {isValidDate, parseDate, fromNow, isWithinDay, isWithinHalfYear, formatDate} from './date-util.js';
+import {isValidDate, parseDate, fromNow, isWithinDay, isWithinHalfYear, formatDate, wasYesterday} from './date-util.js';
 
 suite('date-util tests', () => {
   suite('parseDate', () => {
@@ -53,6 +53,11 @@
       assert.equal('1 year ago', fromNow(new Date('May 05 2019 12:00:00')));
       assert.equal('10 years ago', fromNow(new Date('May 05 2010 12:00:00')));
     });
+    test('rounding error', () => {
+      const fakeNow = new Date('May 08 2020 12:00:00');
+      sinon.useFakeTimers(fakeNow.getTime());
+      assert.equal('2 hours ago', fromNow(new Date('May 08 2020 9:30:00')));
+    });
   });
 
   suite('isWithinDay', () => {
@@ -64,6 +69,21 @@
     });
   });
 
+  suite('wasYesterday', () => {
+    test('less 24 hours', () => {
+      assert.isFalse(wasYesterday(new Date('May 08 2020 12:00:00'),
+          new Date('May 08 2020 02:00:00')));
+      assert.isTrue(wasYesterday(new Date('May 08 2020 12:00:00'),
+          new Date('May 07 2020 12:00:00')));
+    });
+    test('more 24 hours', () => {
+      assert.isTrue(wasYesterday(new Date('May 08 2020 12:00:00'),
+          new Date('May 07 2020 2:00:00')));
+      assert.isFalse(wasYesterday(new Date('May 08 2020 12:00:00'),
+          new Date('May 06 2020 14:00:00')));
+    });
+  });
+
   suite('isWithinHalfYear', () => {
     test('basics works', () => {
       assert.isTrue(isWithinHalfYear(new Date('May 08 2020 12:00:00'),
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index c8920b2..3affef7 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -15,9 +15,8 @@
  * limitations under the License.
  */
 
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
+import {check} from './common-util';
 
 /**
  * Event emitted from polymer elements.
@@ -70,27 +69,9 @@
 
 /**
  * Get computed style value.
- *
- * If ShadyCSS is provided, use ShadyCSS api.
- * If `getComputedStyleValue` is provided on the element, use it.
- * Otherwise fallback to native method (in polymer 2).
- *
  */
-export function getComputedStyleValue(
-  name: string,
-  el: Element | LegacyElementMixin
-) {
-  let style;
-  if (window.ShadyCSS) {
-    style = window.ShadyCSS.getComputedStyleValue(el as Element, name);
-    // `getComputedStyleValue` defined through LegacyElementMixin
-    // TODO: It should be safe to just use `getComputedStyle`, but just to be safe
-  } else if ('getComputedStyleValue' in el) {
-    style = el.getComputedStyleValue(name);
-  } else {
-    style = getComputedStyle(el).getPropertyValue(name);
-  }
-  return style;
+export function getComputedStyleValue(name: string, el: Element) {
+  return getComputedStyle(el).getPropertyValue(name).trim();
 }
 
 /**
@@ -173,6 +154,12 @@
   return [...results];
 }
 
+export function windowLocationReload() {
+  const e = new Error();
+  console.info(`Calling window.location.reload(): ${e.stack}`);
+  window.location.reload();
+}
+
 /**
  * Retrieves the dom path of the current event.
  *
@@ -236,24 +223,6 @@
   return `${prefix}${str.replace(/[^a-zA-Z0-9-_]/g, '_')}`;
 }
 
-// shared API element
-// TODO: Make this a proper service singleton. Move into AppContext?
-let _sharedApiEl: JsApiService;
-
-/**
- * Retrieves the shared API element.
- * We want to keep a single instance of API element instead of
- * creating multiple elements.
- */
-export function getSharedApiEl(): JsApiService {
-  if (!_sharedApiEl) {
-    _sharedApiEl = (document.createElement(
-      'gr-js-api-interface'
-    ) as unknown) as JsApiService;
-  }
-  return _sharedApiEl;
-}
-
 // document.activeElement is not enough, because it's not getting activeElement
 // without looking inside of shadow roots. This will find best activeElement.
 export function findActiveElement(
@@ -290,3 +259,36 @@
     (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream)
   );
 }
+
+export function whenVisible(
+  element: Element,
+  callback: () => void,
+  marginPx = 0
+) {
+  const observer = new IntersectionObserver(
+    (entries: IntersectionObserverEntry[]) => {
+      check(entries.length === 1, 'Expected one intersection observer entry.');
+      const entry = entries[0];
+      if (entry.isIntersecting) {
+        observer.unobserve(entry.target);
+        callback();
+      }
+    },
+    {rootMargin: `${marginPx}px`}
+  );
+  observer.observe(element);
+}
+
+/**
+ * Toggles a CSS class on or off for an element.
+ */
+export function toggleClass(el: Element, className: string, bool?: boolean) {
+  if (bool === undefined) {
+    bool = !el.classList.contains(className);
+  }
+  if (bool) {
+    el.classList.add(className);
+  } else {
+    el.classList.remove(className);
+  }
+}
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
new file mode 100644
index 0000000..da075f1
--- /dev/null
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -0,0 +1,146 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {UrlEncodedCommentId} from '../types/common';
+import {FetchRequest} from '../types/types';
+import {
+  DialogChangeEventDetail,
+  EventType,
+  SwitchTabEventDetail,
+  TabState,
+} from '../types/events';
+
+export function fireEvent(target: EventTarget, type: string) {
+  target.dispatchEvent(
+    new CustomEvent(type, {
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+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;
+
+export function fire<K extends keyof DocumentEventMap>(
+  target: Document,
+  type: K,
+  detail: DocumentEventDetailType<K>
+): void;
+
+export function fire<K extends keyof HTMLElementEventMap>(
+  target: EventTarget,
+  type: K,
+  detail: HTMLElementEventDetailType<K>
+): void;
+
+export function fire<T>(target: EventTarget, type: string, detail: T) {
+  target.dispatchEvent(
+    new CustomEvent<T>(type, {
+      detail,
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export function fireAlert(target: EventTarget, message: string) {
+  fire(target, EventType.SHOW_ALERT, {message});
+}
+
+export function firePageError(response?: Response | null) {
+  if (response === null) response = undefined;
+  fire(document, EventType.PAGE_ERROR, {response});
+}
+
+export function fireServerError(response: Response, request?: FetchRequest) {
+  fire(document, EventType.SERVER_ERROR, {
+    response,
+    request,
+  });
+}
+
+export function fireNetworkError(error: Error) {
+  fire(document, EventType.NETWORK_ERROR, {error});
+}
+
+export function fireTitleChange(target: EventTarget, title: string) {
+  fire(target, EventType.TITLE_CHANGE, {title});
+}
+
+// TODO(milutin) - remove once new gr-dialog will do it out of the box
+// This informs gr-app-element to remove footer, header from a11y tree
+export function fireDialogChange(
+  target: EventTarget,
+  detail: DialogChangeEventDetail
+) {
+  fire(target, EventType.DIALOG_CHANGE, detail);
+}
+
+export function fireIronAnnounce(target: EventTarget, text: string) {
+  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,
+  scrollIntoView?: boolean,
+  tabState?: TabState
+) {
+  const detail: SwitchTabEventDetail = {tab, scrollIntoView, tabState};
+  fire(target, EventType.SHOW_PRIMARY_TAB, detail);
+}
+
+export function fireCloseFixPreview(target: EventTarget, fixApplied: boolean) {
+  fire(target, EventType.CLOSE_FIX_PREVIEW, {fixApplied});
+}
+
+export function waitForEventOnce<K extends keyof HTMLElementEventMap>(
+  el: EventTarget,
+  eventName: K
+): Promise<HTMLElementEventMap[K]> {
+  return new Promise<HTMLElementEventMap[K]>(resolve => {
+    const callback = (event: HTMLElementEventMap[K]) => {
+      resolve(event);
+    };
+    el.addEventListener(eventName, callback as EventListener, {once: true});
+  });
+}
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 4313745..4eed0a0 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -15,7 +15,9 @@
  * limitations under the License.
  */
 import {
+  AccountInfo,
   ApprovalInfo,
+  DetailedLabelInfo,
   isDetailedLabelInfo,
   LabelInfo,
   VotingRangeInfo,
@@ -25,7 +27,7 @@
 export const CODE_REVIEW = 'Code-Review';
 
 export function getVotingRange(label?: LabelInfo): VotingRangeInfo | undefined {
-  if (!label || !isDetailedLabelInfo(label)) return undefined;
+  if (!label || !isDetailedLabelInfo(label) || !label.values) return undefined;
   const values = Object.keys(label.values).map(v => Number(v));
   values.sort((a, b) => a - b);
   if (!values.length) return undefined;
@@ -42,3 +44,18 @@
   const votingRange = getVotingRangeOrDefault(label);
   return label.all.filter(account => account.value === votingRange.max);
 }
+
+export function getApprovalInfo(
+  label: DetailedLabelInfo,
+  account: AccountInfo
+): ApprovalInfo | undefined {
+  return label.all?.filter(x => x._account_id === account._account_id)[0];
+}
+
+export function labelCompare(labelName1: string, labelName2: string) {
+  if (labelName1 === CODE_REVIEW && labelName2 === CODE_REVIEW) return 0;
+  if (labelName1 === CODE_REVIEW) return -1;
+  if (labelName2 === CODE_REVIEW) return 1;
+
+  return labelName1.localeCompare(labelName2);
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.js b/polygerrit-ui/app/utils/label-util_test.js
index d6f7b3e..f9a30df 100644
--- a/polygerrit-ui/app/utils/label-util_test.js
+++ b/polygerrit-ui/app/utils/label-util_test.js
@@ -20,6 +20,8 @@
   getVotingRange,
   getVotingRangeOrDefault,
   getMaxAccounts,
+  getApprovalInfo,
+  labelCompare,
 } from './label-util.js';
 
 const VALUES_1 = {
@@ -87,4 +89,36 @@
     assert.isEmpty(getMaxAccounts({}));
     assert.isEmpty(getMaxAccounts({values: VALUES_2}));
   });
+
+  test('getApprovalInfo', () => {
+    const myAccountInfo = {_account_id: 314};
+    const myApprovalInfo = {value: 2, _account_id: 314};
+    const label = {
+      values: VALUES_2,
+      all: [myApprovalInfo, {value: 1, _account_id: 777}],
+    };
+    assert.equal(
+        getApprovalInfo(label, myAccountInfo),
+        myApprovalInfo
+    );
+  });
+
+  test('getApprovalInfo no approval for user', () => {
+    const myAccountInfo = {_account_id: 123};
+    const label = {
+      values: VALUES_2,
+      all: [
+        {value: 2, _account_id: 314},
+        {value: 1, _account_id: 777},
+      ],
+    };
+    assert.isUndefined(getApprovalInfo(label, myAccountInfo));
+  });
+
+  test('labelCompare', () => {
+    let sorted = ['c', 'b', 'a'].sort(labelCompare);
+    assert.sameOrderedMembers(sorted, ['a', 'b', 'c']);
+    sorted = ['b', 'a', 'Code-Review'].sort(labelCompare);
+    assert.sameOrderedMembers(sorted, ['Code-Review', 'a', 'b']);
+  });
 });
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 8974af8..138479c 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -3,14 +3,14 @@
   ChangeInfo,
   PatchSetNum,
   EditPatchSetNum,
-  BrandType,
   ParentPatchSetNum,
+  PatchSetNumber,
+  BasePatchSetNum,
+  RevisionPatchSetNum,
 } from '../types/common';
-import {RestApiService} from '../services/services/gr-rest-api/gr-rest-api';
-import {
-  EditRevisionInfo,
-  ParsedChangeInfo,
-} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
+import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
+import {check} from './common-util';
 
 /**
  * @license
@@ -38,12 +38,7 @@
 // Tags identifying ChangeMessages that move change out of WIP state.
 const READY_TAGS = ['autogenerated:gerrit:setReadyForReview'];
 
-// TODO(TS): Replace usages of these constants by
-// EditPatchSetNum and ParentPatchSetNum in common.ts.
-export const SPECIAL_PATCH_SET_NUM = {
-  EDIT: 'edit',
-  PARENT: 'PARENT',
-};
+export const CURRENT = 'current';
 
 export interface PatchSet {
   num: PatchSetNum;
@@ -54,20 +49,7 @@
 
 interface PatchRange {
   patchNum?: PatchSetNum;
-  basePatchNum?: PatchSetNum;
-}
-
-/**
- * As patchNum can be either a string (e.g. 'edit', 'PARENT') OR a number,
- * this function checks for patchNum equality.
- *
- */
-export function patchNumEquals(a?: PatchSetNum, b?: PatchSetNum) {
-  if (a === undefined) {
-    return a === b;
-  }
-  // TODO(TS): replace with a===b when the whole code is converted to ts
-  return `${a}` === `${b}`;
+  basePatchNum?: BasePatchSetNum;
 }
 
 /**
@@ -78,6 +60,14 @@
   return `${n}`[0] === '-';
 }
 
+/**
+ * Whether the given patch is a parent, either a regular parent or a merge
+ * parent.
+ */
+export function isAParent(n: PatchSetNum) {
+  return n === ParentPatchSetNum || isMergeParent(n);
+}
+
 export function isPatchSetNum(patchset: string) {
   if (!isNaN(Number(patchset))) return true;
   return patchset === EditPatchSetNum || patchset === ParentPatchSetNum;
@@ -95,9 +85,7 @@
   return patchset as PatchSetNum;
 }
 
-export function isNumber(
-  psn: PatchSetNum
-): psn is BrandType<number, '_patchSet'> {
+export function isNumber(psn: PatchSetNum): psn is PatchSetNumber {
   return typeof psn === 'number';
 }
 
@@ -112,7 +100,7 @@
   patchNum: PatchSetNum
 ) {
   for (const rev of revisions) {
-    if (patchNumEquals(rev._number, patchNum)) {
+    if (rev._number === patchNum) {
       return rev;
     }
   }
@@ -120,6 +108,16 @@
   return;
 }
 
+export function getShaByPatchNum(
+  revisions: {[revisionId: string]: RevisionInfo | EditRevisionInfo},
+  patchNum: RevisionPatchSetNum
+) {
+  for (const [sha, rev] of Object.entries(revisions)) {
+    if (rev._number === patchNum) return sha;
+  }
+  return undefined;
+}
+
 /**
  * Find change edit base revision if change edit exists.
  *
@@ -196,11 +194,9 @@
  *     above
  */
 export function computeAllPatchSets(
-  change: ChangeInfo | ParsedChangeInfo
+  change: ChangeInfo | ParsedChangeInfo | undefined
 ): PatchSet[] {
-  if (!change) {
-    return [];
-  }
+  if (!change) return [];
 
   let patchNums: PatchSet[] = [];
   if (change.revisions && Object.keys(change.revisions).length) {
@@ -238,7 +234,7 @@
     return patchNums;
   }
   // TODO(TS): replace with Map<PatchNum, boolean>
-  const psWip: Map<string, boolean> = new Map();
+  const psWip: Map<string, boolean> = new Map<string, boolean>();
   let wip = !!change.work_in_progress;
   for (let i = 0; i < change.messages.length; i++) {
     const msg = change.messages[i];
@@ -265,14 +261,30 @@
 
 export function computeLatestPatchNum(
   allPatchSets?: PatchSet[]
-): PatchSetNum | undefined {
+): PatchSetNumber | undefined {
   if (!allPatchSets || !allPatchSets.length) {
     return undefined;
   }
-  if (allPatchSets[0].num === EditPatchSetNum) {
-    return allPatchSets[1].num;
+  let latest = allPatchSets[0].num;
+  if (latest === EditPatchSetNum) {
+    latest = allPatchSets[1].num;
   }
-  return allPatchSets[0].num;
+  check(isNumber(latest), 'Latest patchset cannot be EDIT or PARENT.');
+  return latest;
+}
+
+export function computePredecessor(
+  patchset?: PatchSetNum
+): BasePatchSetNum | undefined {
+  if (
+    !patchset ||
+    patchset === ParentPatchSetNum ||
+    patchset === EditPatchSetNum
+  ) {
+    return undefined;
+  }
+  if (patchset === 1) return ParentPatchSetNum;
+  return (Number(patchset) - 1) as BasePatchSetNum;
 }
 
 export function hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]) {
@@ -316,7 +328,9 @@
       isLatest: actualLatest <= knownLatest,
       newStatus: change.status !== detail.status ? detail.status : null,
       newMessages:
-        (change.messages || []).length < (detail.messages || []).length,
+        (change.messages || []).length < (detail.messages || []).length
+          ? detail.messages![detail.messages!.length - 1]
+          : undefined,
     };
   });
 }
diff --git a/polygerrit-ui/app/utils/patch-set-util_test.js b/polygerrit-ui/app/utils/patch-set-util_test.js
index 29cc370..eefdcda 100644
--- a/polygerrit-ui/app/utils/patch-set-util_test.js
+++ b/polygerrit-ui/app/utils/patch-set-util_test.js
@@ -21,7 +21,7 @@
   fetchChangeUpdates, findEditParentPatchNum, findEditParentRevision,
   getParentIndex, getRevisionByPatchNum,
   isMergeParent,
-  patchNumEquals, sortRevisions,
+  sortRevisions,
 } from './patch-set-util.js';
 
 suite('gr-patch-set-util tests', () => {
@@ -31,9 +31,9 @@
       {_number: 1},
       {_number: 2},
     ];
-    assert.deepEqual(getRevisionByPatchNum(revisions, '1'), revisions[1]);
+    assert.deepEqual(getRevisionByPatchNum(revisions, 1), revisions[1]);
     assert.deepEqual(getRevisionByPatchNum(revisions, 2), revisions[2]);
-    assert.equal(getRevisionByPatchNum(revisions, '3'), undefined);
+    assert.equal(getRevisionByPatchNum(revisions, 3), undefined);
   });
 
   test('fetchChangeUpdates on latest', done => {
@@ -54,7 +54,7 @@
         .then(result => {
           assert.isTrue(result.isLatest);
           assert.isNotOk(result.newStatus);
-          assert.isFalse(result.newMessages);
+          assert.isNotOk(result.newMessages);
           done();
         });
   });
@@ -86,7 +86,7 @@
         .then(result => {
           assert.isFalse(result.isLatest);
           assert.isNotOk(result.newStatus);
-          assert.isFalse(result.newMessages);
+          assert.isNotOk(result.newMessages);
           done();
         });
   });
@@ -117,7 +117,7 @@
         .then(result => {
           assert.isTrue(result.isLatest);
           assert.equal(result.newStatus, 'MERGED');
-          assert.isFalse(result.newMessages);
+          assert.isNotOk(result.newMessages);
           done();
         });
   });
@@ -148,7 +148,7 @@
         .then(result => {
           assert.isTrue(result.isLatest);
           assert.isNotOk(result.newStatus);
-          assert.isTrue(result.newMessages);
+          assert.deepEqual(result.newMessages, {message: 'blah blah'});
           done();
         });
   });
@@ -231,17 +231,6 @@
         .assertWip(6, true); // PS6 was uploaded with WIP option
   });
 
-  test('patchNumEquals', () => {
-    assert.isFalse(patchNumEquals('edit', 'PARENT'));
-    assert.isFalse(patchNumEquals('edit', NaN));
-    assert.isFalse(patchNumEquals(1, '2'));
-
-    assert.isTrue(patchNumEquals(1, '1'));
-    assert.isTrue(patchNumEquals(1, 1));
-    assert.isTrue(patchNumEquals('edit', 'edit'));
-    assert.isTrue(patchNumEquals('PARENT', 'PARENT'));
-  });
-
   test('isMergeParent', () => {
     assert.isFalse(isMergeParent(1));
     assert.isFalse(isMergeParent(4321));
diff --git a/polygerrit-ui/app/utils/safari-selection-util.ts b/polygerrit-ui/app/utils/safari-selection-util.ts
index 9c56c56..e38111f 100644
--- a/polygerrit-ui/app/utils/safari-selection-util.ts
+++ b/polygerrit-ui/app/utils/safari-selection-util.ts
@@ -19,7 +19,8 @@
 const SUPPORTS_SHADOW_SELECTION =
   typeof window.ShadowRoot.prototype.getSelection === 'function';
 const SUPPORTS_BEFORE_INPUT =
-  typeof (window.InputEvent.prototype as any).getTargetRanges === 'function';
+  typeof (window.InputEvent.prototype as InputEventExtended).getTargetRanges ===
+  'function';
 
 const TARGET_ID = 'diffTable';
 
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
new file mode 100644
index 0000000..6aae67f
--- /dev/null
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Returns a count plus string that is pluralized when necessary.
+ */
+export function pluralize(count: number, noun: string): string {
+  if (count === 0) return '';
+  return `${count} ${noun}` + (count > 1 ? 's' : '');
+}
+
+export function addQuotesWhen(string: string, cond: boolean): string {
+  return cond ? `"${string}"` : string;
+}
+
+export function charsOnly(s: string): string {
+  return s.replace(/[^a-zA-Z]+/g, '');
+}
diff --git a/polygerrit-ui/app/utils/string-util_test.ts b/polygerrit-ui/app/utils/string-util_test.ts
new file mode 100644
index 0000000..2eef50f
--- /dev/null
+++ b/polygerrit-ui/app/utils/string-util_test.ts
@@ -0,0 +1,28 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma';
+import {pluralize} from './string-util';
+
+suite('formatter util tests', () => {
+  test('pluralize', () => {
+    const noun = 'comment';
+    assert.equal(pluralize(0, noun), '');
+    assert.equal(pluralize(1, noun), '1 comment');
+    assert.equal(pluralize(2, noun), '2 comments');
+  });
+});
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 0c6fabc..4115062 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -1,5 +1,5 @@
 import {ServerInfo} from '../types/common';
-import {RestApiService} from '../services/services/gr-rest-api/gr-rest-api';
+import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 
 /**
  * @license
@@ -78,3 +78,33 @@
   const withoutPlus = url.replace(/\+/g, '%20');
   return decodeURIComponent(withoutPlus);
 }
+
+/**
+ * @param path URL path including search params, but without host
+ */
+export function toPathname(path: string) {
+  const i = path.indexOf('?');
+  const hasQuery = i > -1;
+  const pathname = hasQuery ? path.slice(0, i) : path;
+  return pathname;
+}
+
+/**
+ * @param path URL path including search params, but without host
+ */
+export function toSearchParams(path: string) {
+  const i = path.indexOf('?');
+  const hasQuery = i > -1;
+  const querystring = hasQuery ? path.slice(i + 1) : '';
+  return new URLSearchParams(querystring);
+}
+
+/**
+ * @param pathname URL path without search params
+ * @param params
+ */
+export function toPath(pathname: string, searchParams: URLSearchParams) {
+  const paramString = searchParams.toString();
+  const middle = paramString ? '?' : '';
+  return pathname + middle + paramString;
+}
diff --git a/polygerrit-ui/app/utils/url-util_test.js b/polygerrit-ui/app/utils/url-util_test.js
index 0658be3..5cd4bb4 100644
--- a/polygerrit-ui/app/utils/url-util_test.js
+++ b/polygerrit-ui/app/utils/url-util_test.js
@@ -20,20 +20,24 @@
   getBaseUrl,
   getDocsBaseUrl,
   _testOnly_clearDocsBaseUrlCache,
-  encodeURL, singleDecodeURL,
+  encodeURL,
+  singleDecodeURL,
+  toPath,
+  toPathname,
+  toSearchParams,
 } from './url-util.js';
 
 suite('url-util tests', () => {
   suite('getBaseUrl tests', () => {
-    let originialCanonicalPath;
+    let originalCanonicalPath;
 
     suiteSetup(() => {
-      originialCanonicalPath = window.CANONICAL_PATH;
+      originalCanonicalPath = window.CANONICAL_PATH;
       window.CANONICAL_PATH = '/r';
     });
 
     suiteTeardown(() => {
-      window.CANONICAL_PATH = originialCanonicalPath;
+      window.CANONICAL_PATH = originalCanonicalPath;
     });
 
     test('getBaseUrl', () => {
@@ -124,4 +128,23 @@
       });
     });
   });
+
+  test('toPathname', () => {
+    assert.equal(toPathname('asdf'), 'asdf');
+    assert.equal(toPathname('asdf?qwer=zxcv'), 'asdf');
+  });
+
+  test('toSearchParams', () => {
+    assert.equal(toSearchParams('asdf').toString(), '');
+    assert.equal(toSearchParams('asdf?qwer=zxcv').get('qwer'), 'zxcv');
+  });
+
+  test('toPathname', () => {
+    const params = new URLSearchParams();
+    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');
+  });
 });
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index f6664fd..96597c9 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -19,7 +19,7 @@
   resolved "https://registry.yarnpkg.com/@polymer/font-roboto/-/font-roboto-3.0.2.tgz#80cdaa7225db2359130dfb2c6d9a3be1820020c3"
   integrity sha512-tx5TauYSmzsIvmSqepUPDYbs4/Ejz2XbZ1IkD7JEGqkdNUJlh+9KU85G56Tfdk/xjEZ8zorFfN09OSwiMrIQWA==
 
-"@polymer/iron-a11y-announcer@^3.0.0-pre.26", "@polymer/iron-a11y-announcer@^3.0.1":
+"@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==
@@ -60,7 +60,7 @@
     "@polymer/iron-validatable-behavior" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/iron-dropdown@^3.0.1":
+"@polymer/iron-dropdown@^3.0.0-pre.26", "@polymer/iron-dropdown@^3.0.1":
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/@polymer/iron-dropdown/-/iron-dropdown-3.0.1.tgz#c573faa1a08c179d201ae877c1c726018314bff3"
   integrity sha512-22yLhepfcKjuQMfFmRHi/9MPKTqkzgRrmWWW0P5uqK++xle53k2QBO5VYUAYiCN3ZcxIi9lEhZ9YWGeQj2JBig==
@@ -70,10 +70,10 @@
     "@polymer/neon-animation" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/iron-fit-behavior@^3.0.0-pre.26", "@polymer/iron-fit-behavior@^3.0.2":
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/@polymer/iron-fit-behavior/-/iron-fit-behavior-3.0.2.tgz#2ec460d8a6b0151394b55631a72a68b92e14e2e0"
-  integrity sha512-JndryJYbBR3gSN5IlST4rCHsd01+OyvYpRO6z5Zd3C6u5V/m07TwAtcf3aXwZ8WBNt2eLG28OcvdSO7XR2v2pg==
+"@polymer/iron-fit-behavior@^3.0.0-pre.26", "@polymer/iron-fit-behavior@^3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-fit-behavior/-/iron-fit-behavior-3.1.0.tgz#8197a61e0ce2234e9bf8fbd7a1ae53d1a06026b0"
+  integrity sha512-ABcgIYqrjhmUT8tiuolqeGttF/8pd3sEymUDrO1vXbZu4FWIvoLNndrMDFvs++AGd12Mjf5pYy84NJc6dB8Vig==
   dependencies:
     "@polymer/polymer" "^3.0.0"
 
@@ -108,6 +108,13 @@
     "@polymer/iron-meta" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
+"@polymer/iron-image@^3.0.0-pre.26":
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-image/-/iron-image-3.0.2.tgz#425ee6269634e024dbea726a91a61724ae4402b6"
+  integrity sha512-VyYtnewGozDb5sUeoLR1OvKzlt5WAL6b8Od7fPpio5oYL+9t061/nTV8+ZMrpMgF2WgB0zqM/3K53o3pbK5v8Q==
+  dependencies:
+    "@polymer/polymer" "^3.0.0"
+
 "@polymer/iron-input@^3.0.0-pre.26", "@polymer/iron-input@^3.0.1":
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/@polymer/iron-input/-/iron-input-3.0.1.tgz#dc866a25107f9b38d9ca4512dd9a3e51b78b4915"
@@ -195,6 +202,28 @@
     "@polymer/paper-styles" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
+"@polymer/paper-card@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-card/-/paper-card-3.0.1.tgz#fb5960b3e55fab56d20b7c1c3dee08f0d052ff2a"
+  integrity sha512-ZYzfA4kzP9niRO22wSOBL2RS+URZNUP5XmUCwN91fYPIGO0Qbimh7d1O2HpJD4cRCZhvGYn2CJMDMVmDm35vIg==
+  dependencies:
+    "@polymer/iron-flex-layout" "^3.0.0-pre.26"
+    "@polymer/iron-image" "^3.0.0-pre.26"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/paper-checkbox@^3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-checkbox/-/paper-checkbox-3.1.0.tgz#66b903ae5814db237d027deb4a3f3430f48d905b"
+  integrity sha512-kXm6yDG1tT8if0XuJ2cc9NF+g8Ev4wG+rnf0a+Sx+O7J6fn1jcnBlYn72FlrfjVjDQZDBFmT6nynhD5PvFw8iQ==
+  dependencies:
+    "@polymer/iron-a11y-keys-behavior" "^3.0.0-pre.26"
+    "@polymer/iron-checked-element-behavior" "^3.0.0-pre.26"
+    "@polymer/paper-behaviors" "^3.0.0-pre.27"
+    "@polymer/paper-ripple" "^3.0.0-pre.26"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
 "@polymer/paper-dialog-behavior@^3.0.0-pre.26", "@polymer/paper-dialog-behavior@^3.0.1":
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/@polymer/paper-dialog-behavior/-/paper-dialog-behavior-3.0.1.tgz#819b2fbb9444c1c318ddf55f02819bb29a85657b"
@@ -224,6 +253,23 @@
     "@polymer/paper-dialog-behavior" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
+"@polymer/paper-dropdown-menu@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-dropdown-menu/-/paper-dropdown-menu-3.2.0.tgz#17f507ef864dc18365a92e256bde628c2d352956"
+  integrity sha512-2ohwSHF+RLSK6kA0UkkMiMQF6EZcaEYWAA25kfisI6DWie7yozKrpQNsqvwfOEHU6DdDMIotrOtH1TM88YS8Zg==
+  dependencies:
+    "@polymer/iron-a11y-keys-behavior" "^3.0.0-pre.26"
+    "@polymer/iron-form-element-behavior" "^3.0.0-pre.26"
+    "@polymer/iron-icon" "^3.0.0-pre.26"
+    "@polymer/iron-iconset-svg" "^3.0.0-pre.26"
+    "@polymer/iron-validatable-behavior" "^3.0.0-pre.26"
+    "@polymer/paper-behaviors" "^3.0.0-pre.27"
+    "@polymer/paper-input" "^3.1.0"
+    "@polymer/paper-menu-button" "^3.1.0"
+    "@polymer/paper-ripple" "^3.0.0-pre.26"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.3.1"
+
 "@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"
@@ -234,7 +280,7 @@
     "@polymer/paper-styles" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/paper-input@^3.2.1":
+"@polymer/paper-input@^3.1.0", "@polymer/paper-input@^3.2.1":
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/@polymer/paper-input/-/paper-input-3.2.1.tgz#0fd0d30de3b43ba7d2c8d5d76f870d257b667ebf"
   integrity sha512-6ghgwQKM6mS0hAQxQqj+tkeEY1VUBqAsrasAm8V5RpNcfSWQC/hhRFxU0beGuKTAhndzezDzWYP6Zz4b8fExGg==
@@ -267,6 +313,19 @@
     "@polymer/paper-styles" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
+"@polymer/paper-menu-button@^3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-menu-button/-/paper-menu-button-3.1.0.tgz#3eb2fb88ba3cdaa4c39be6ae3530efa40b1eccf3"
+  integrity sha512-q0G0/rvYD/FFmIBMGCQWjfXzRqwFw9+WHSYV4uOQzM1Ln8LMXSAd+2CENsbVwtMh6fmBePj15ZlU8SM2dt1WDQ==
+  dependencies:
+    "@polymer/iron-a11y-keys-behavior" "^3.0.0-pre.26"
+    "@polymer/iron-behaviors" "^3.0.0-pre.26"
+    "@polymer/iron-dropdown" "^3.0.0-pre.26"
+    "@polymer/iron-fit-behavior" "^3.1.0"
+    "@polymer/neon-animation" "^3.0.0-pre.26"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
 "@polymer/paper-ripple@^3.0.0-pre.26":
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/@polymer/paper-ripple/-/paper-ripple-3.0.2.tgz#52566f5ee367942022ceaa991368105d21403de5"
@@ -275,7 +334,7 @@
     "@polymer/iron-a11y-keys-behavior" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/paper-styles@^3.0.0-pre.26":
+"@polymer/paper-styles@^3.0.0-pre.26", "@polymer/paper-styles@^3.0.1":
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/@polymer/paper-styles/-/paper-styles-3.0.1.tgz#bd4962b83ab8432cd1cf197bb5222d3a08f135e1"
   integrity sha512-y6hmObLqlCx602TQiSBKHqjwkE7xmDiFkoxdYGaNjtv4xcysOTdVJsDR/R9UHwIaxJ7gHlthMSykir1nv78++g==
@@ -310,24 +369,30 @@
     "@polymer/paper-styles" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/polymer@^3.0.0", "@polymer/polymer@^3.0.5", "@polymer/polymer@^3.4.1":
+"@polymer/paper-tooltip@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-tooltip/-/paper-tooltip-3.0.1.tgz#cdbb06442737513f081437c6302842170ce714dc"
+  integrity sha512-yiUk09opTEnE1lK+tb501ENb+yQBi4p++Ep0eGJAHesVYKVMPNgPphVKkIizkDaU+n0SE+zXfTsRbYyOMDYXSg==
+  dependencies:
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
+"@polymer/polymer@^3.0.0", "@polymer/polymer@^3.0.2", "@polymer/polymer@^3.0.5", "@polymer/polymer@^3.3.1", "@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"
 
-"@polymer/polymer@^3.0.2":
-  version "3.3.1"
-  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.3.1.tgz#9ad48992d2a96775f80b0673f3a615d6df8a3dfc"
-  integrity sha512-8KaB48tzyMjdsHdxo5KvCAaqmTe7rYDzQAoj/pyEfq9Fp4YfUaS+/xqwYj0GbiDAUNzwkmEQ7dw9cgnRNdKO8A==
-  dependencies:
-    "@webcomponents/shadycss" "^1.9.1"
+"@types/resize-observer-browser@^0.1.5":
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.5.tgz#36d897708172ac2380cd486da7a3daf1161c1e23"
+  integrity sha512-8k/67Z95Goa6Lznuykxkfhq9YU3l1Qe6LNZmwde1u7802a3x8v44oq0j91DICclxatTr0rNnhXx7+VTIetSrSQ==
 
-"@webcomponents/shadycss@^1.9.1", "@webcomponents/shadycss@^1.9.2":
-  version "1.9.4"
-  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.9.4.tgz#4f9d8ea1526bab084c60b53d4854dc39fdb2bb48"
-  integrity sha512-tgNcVEaKssyeZPbUBjVQf4aryO5Fi7fxRvOxV982ZJuRVDcefmIblBh0SXAbcvAAlQ2zpNEP4SuQUnr8uApIpw==
+"@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==
 
 "@webcomponents/webcomponentsjs@^1.3.3":
   version "1.3.3"
@@ -335,9 +400,9 @@
   integrity sha512-eLH04VBMpuZGzBIhOnUjECcQPEPcmfhWEijW9u1B5I+2PPYdWf3vWUExdDxu4Y3GljRSTCOlWnGtS9tpzmXMyQ==
 
 "@webcomponents/webcomponentsjs@^2.0.3":
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.4.1.tgz#7baadec56ed2fd79b94ddfd509132d8c0c295c5c"
-  integrity sha512-7jxBb+KoWncKb/JGFyTY40PjV4yRx2zd35ZLuvRP+6WndJDL7X32ZIZ7bN3sSQIl+NzJkCo7chfXJyzn+6WZaQ==
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.5.0.tgz#61b27785a6ad5bfd68fa018201fe418b118cb38d"
+  integrity sha512-C0l51MWQZ9kLzcxOZtniOMohpIFdCLZum7/TEHv3XWFc1Fvt5HCpbSX84x8ltka/JuNKcuiDnxXFkiB2gaePcg==
 
 "ba-linkify@file:../../lib/ba-linkify/src":
   version "1.0.0"
@@ -347,10 +412,22 @@
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
   integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
 
-page@^1.11.5:
-  version "1.11.5"
-  resolved "https://registry.yarnpkg.com/page/-/page-1.11.5.tgz#0cfc8608be337f26f4377f31df0787aef0ca1af7"
-  integrity sha512-0JXUHc7Y8p1cPJQbhZSwaKO3p+bU3Rgny+OM5gJMKHWHvJKan/fsE5RUzEjRQolv9DzPOSVWfSOHz0lLxK19eA==
+lit-element@^2.4.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-2.4.0.tgz#b22607a037a8fc08f5a80736dddf7f3f5d401452"
+  integrity sha512-pBGLglxyhq/Prk2H91nA0KByq/hx/wssJBQFiYqXhGDvEnY31PRGYf1RglVzyLeRysu0IHm2K0P196uLLWmwFg==
+  dependencies:
+    lit-html "^1.1.1"
+
+lit-html@^1.1.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-1.3.0.tgz#c80f3cc5793a6dea6c07172be90a70ab20e56034"
+  integrity sha512-0Q1bwmaFH9O14vycPHw8C/IeHMk/uSDldVLIefu/kfbTBGIc44KGH6A8p1bDfxUfHdc8q6Ct7kQklWoHgr4t1Q==
+
+page@^1.11.6:
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/page/-/page-1.11.6.tgz#5ef4efc7073749b8085ccdaa0dcd7c9e0de12fe3"
+  integrity sha512-P6e2JfzkBrPeFCIPplLP7vDDiU84RUUZMrWdsH4ZBGJ8OosnwFkcUkBHp1DTIjuipLliw9yQn/ZJsXZvarsO+g==
   dependencies:
     path-to-regexp "~1.2.1"
 
@@ -372,3 +449,14 @@
     "@polymer/polymer" "^3.0.2"
     "@webcomponents/webcomponentsjs" "^2.0.3"
 
+rxjs@^6.6.7:
+  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"
+
+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==
diff --git a/polygerrit-ui/karma.conf.js b/polygerrit-ui/karma.conf.js
index 3f7221a..fe3fa0c 100644
--- a/polygerrit-ui/karma.conf.js
+++ b/polygerrit-ui/karma.conf.js
@@ -163,6 +163,9 @@
     // available reporters: https://npmjs.org/browse/keyword/karma-reporter
     reporters: ['mocha'],
 
+    mochaReporter: {
+      showDiff: true
+    },
 
     // web server port
     port: 9876,
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index c01ef56..3aa0c92 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -3,24 +3,24 @@
   "description": "Gerrit Code Review - Polygerrit dev dependencies",
   "browser": true,
   "dependencies": {
-    "@types/chai": "^4.2.14",
-    "@types/lodash": "^4.14.162",
-    "@types/mocha": "^8.0.3",
-    "@types/sinon": "^9.0.8"
+    "@types/chai": "^4.2.16",
+    "@types/lodash": "^4.14.168",
+    "@types/mocha": "^8.2.2",
+    "@types/sinon": "^10.0.0"
   },
   "devDependencies": {
     "@open-wc/karma-esm": "^2.16.16",
     "@polymer/iron-test-helpers": "^3.0.1",
     "@polymer/test-fixture": "^4.0.2",
     "accessibility-developer-tools": "^2.12.0",
-    "chai": "^4.2.0",
+    "chai": "^4.3.4",
     "karma": "^4.4.1",
     "karma-chrome-launcher": "^3.1.0",
     "karma-mocha": "^2.0.1",
     "karma-mocha-reporter": "^2.2.5",
-    "lodash": "^4.17.15",
-    "mocha": "7.2.0",
-    "sinon": "^9.0.2",
+    "lodash": "^4.17.21",
+    "mocha": "8.3.2",
+    "sinon": "^10.0.0",
     "source-map-support": "^0.5.19"
   },
   "license": "Apache-2.0",
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 124b924..2734f58 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -185,11 +185,23 @@
 		//   'page/page.mjs' -> '/node_modules/page.mjs'
 		//   '@polymer/iron-icon' -> '/node_modules/@polymer/iron-icon.js'
 		//   './element/file' -> './element/file.js'
-		moduleImportRegexp = regexp.MustCompile(`(?m)^(import.*)'(.*?)(\.(m?)js)?';$`)
-		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '$2.${4}js';"))
+		moduleImportRegexp = regexp.MustCompile(`(?m)^(import.*|export.* from )['"](.*?)(\.(m?)js)?['"];$`)
+		data = moduleImportRegexp.ReplaceAll(data, []byte("$1'$2.${4}js';"))
 
-		moduleImportRegexp = regexp.MustCompile("(?m)^(import.*)'([^/.].*)';$")
-		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '/node_modules/$2';"))
+		moduleImportRegexp = regexp.MustCompile(`(?m)^(import.*|export.* from )['"]([^/.].*)['"];$`)
+		data = moduleImportRegexp.ReplaceAll(data, []byte("$1'/node_modules/$2';"))
+
+		// The es module version of rxjs can be found in the _esm2015/ directory.
+		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/rxjs)(.*).js(';)$")
+		data = moduleImportRegexp.ReplaceAll(data, []byte("$1/_esm2015$3/index.js$4"))
+
+		// The es module version of tslib.js can be found in tslib.es6.js.
+		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)tslib.js';$")
+		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}tslib/tslib.es6.js';"))
+
+		// 'lit-element' imports and exports have to be resolved to 'lit-element/lit-element.js'.
+		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)lit-(element|html).js';$")
+		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}lit-${3}/lit-${3}.js';"))
 
 		if strings.HasSuffix(normalizedContentPath, "/node_modules/page/page.js") {
 			// Can't import page.js directly, because this is undefined.
@@ -388,14 +400,8 @@
 		insertionPoint := strings.Index(replaced, "</script>")
 		builder := new(strings.Builder)
 		builder.WriteString(
-			"window.INITIAL_DATA['/config/server/info'].plugin.html_resource_paths = []; ")
-		builder.WriteString(
 			"window.INITIAL_DATA['/config/server/info'].plugin.js_resource_paths = []; ")
 		for _, p := range strings.Split(*plugins, ",") {
-			if filepath.Ext(p) == ".html" {
-				builder.WriteString(
-					"window.INITIAL_DATA['/config/server/info'].plugin.html_resource_paths.push('" + p + "'); ")
-			}
 			if filepath.Ext(p) == ".js" {
 				builder.WriteString(
 					"window.INITIAL_DATA['/config/server/info'].plugin.js_resource_paths.push('" + p + "'); ")
@@ -423,22 +429,15 @@
 
 	// Configuration path in the JSON server response
 	jsPluginsPath := []string{"plugin", "js_resource_paths"}
-	htmlPluginsPath := []string{"plugin", "html_resource_paths"}
-	htmlResources := getJsonPropByPath(response, htmlPluginsPath).([]interface{})
 	jsResources := getJsonPropByPath(response, jsPluginsPath).([]interface{})
 
 	for _, p := range strings.Split(*plugins, ",") {
-		if filepath.Ext(p) == ".html" {
-			htmlResources = append(htmlResources, p)
-		}
-
 		if filepath.Ext(p) == ".js" {
 			jsResources = append(jsResources, p)
 		}
 	}
 
 	setJsonPropByPath(response, jsPluginsPath, jsResources)
-	setJsonPropByPath(response, htmlPluginsPath, htmlResources)
 
 	reader, writer := io.Pipe()
 	go func() {
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index 2acd478..271b295 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -2,387 +2,428 @@
 # yarn lockfile v1
 
 
-"@babel/code-frame@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a"
-  integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==
+"@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==
   dependencies:
-    "@babel/highlight" "^7.10.4"
+    "@babel/highlight" "^7.12.13"
 
-"@babel/compat-data@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.10.4.tgz#706a6484ee6f910b719b696a9194f8da7d7ac241"
-  integrity sha512-t+rjExOrSVvjQQXNp5zAIYDp00KjdvGl/TpDX5REPr0S9IAIPQMTilcfG6q8c0QFmj9lSTVySV2VTsyggvtNIw==
-  dependencies:
-    browserslist "^4.12.0"
-    invariant "^2.2.4"
-    semver "^5.5.0"
+"@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/core@^7.9.0":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.10.4.tgz#780e8b83e496152f8dd7df63892b2e052bf1d51d"
-  integrity sha512-3A0tS0HWpy4XujGc7QtOIHTeNwUgWaZc/WuS5YQrfhU67jnVmsD6OGPc1AKHH0LJHQICGncy3+YUjIhVlfDdcA==
+"@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==
   dependencies:
-    "@babel/code-frame" "^7.10.4"
-    "@babel/generator" "^7.10.4"
-    "@babel/helper-module-transforms" "^7.10.4"
-    "@babel/helpers" "^7.10.4"
-    "@babel/parser" "^7.10.4"
-    "@babel/template" "^7.10.4"
-    "@babel/traverse" "^7.10.4"
-    "@babel/types" "^7.10.4"
+    "@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"
     convert-source-map "^1.7.0"
     debug "^4.1.0"
-    gensync "^1.0.0-beta.1"
+    gensync "^1.0.0-beta.2"
     json5 "^2.1.2"
-    lodash "^4.17.13"
-    resolve "^1.3.2"
-    semver "^5.4.1"
+    semver "^6.3.0"
     source-map "^0.5.0"
 
-"@babel/generator@^7.10.4", "@babel/generator@^7.4.0":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.10.4.tgz#e49eeed9fe114b62fa5b181856a43a5e32f5f243"
-  integrity sha512-toLIHUIAgcQygFZRAQcsLQV3CBuX6yOIru1kJk/qqqvcRmZrYe6WavZTSG+bB8MxhnL9YPf+pKQfuiP161q7ng==
+"@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==
   dependencies:
-    "@babel/types" "^7.10.4"
+    "@babel/types" "^7.13.0"
     jsesc "^2.5.1"
-    lodash "^4.17.13"
     source-map "^0.5.0"
 
-"@babel/helper-annotate-as-pure@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz#5bf0d495a3f757ac3bda48b5bf3b3ba309c72ba3"
-  integrity sha512-XQlqKQP4vXFB7BN8fEEerrmYvHp3fK/rBkRFz9jaJbzK0B1DSfej9Kc7ZzE8Z/OnId1jpJdNAZ3BFQjWG68rcA==
+"@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.10.4"
+    "@babel/types" "^7.12.13"
 
-"@babel/helper-builder-binary-assignment-operator-visitor@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.10.4.tgz#bb0b75f31bf98cbf9ff143c1ae578b87274ae1a3"
-  integrity sha512-L0zGlFrGWZK4PbT8AszSfLTM5sDU1+Az/En9VrdT8/LmEiJt4zXt+Jve9DCAnQcbqDhCI+29y/L93mrDzddCcg==
+"@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.10.4"
-    "@babel/types" "^7.10.4"
+    "@babel/helper-explode-assignable-expression" "^7.12.13"
+    "@babel/types" "^7.12.13"
 
-"@babel/helper-compilation-targets@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.10.4.tgz#804ae8e3f04376607cc791b9d47d540276332bd2"
-  integrity sha512-a3rYhlsGV0UHNDvrtOXBg8/OpfV0OKTkxKPzIplS1zpx7CygDcWWxckxZeDd3gzPzC4kUT0A4nVFDK0wGMh4MQ==
+"@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==
   dependencies:
-    "@babel/compat-data" "^7.10.4"
-    browserslist "^4.12.0"
-    invariant "^2.2.4"
-    levenary "^1.1.1"
-    semver "^5.5.0"
+    "@babel/compat-data" "^7.13.12"
+    "@babel/helper-validator-option" "^7.12.17"
+    browserslist "^4.14.5"
+    semver "^6.3.0"
 
-"@babel/helper-create-class-features-plugin@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.4.tgz#2d4015d0136bd314103a70d84a7183e4b344a355"
-  integrity sha512-9raUiOsXPxzzLjCXeosApJItoMnX3uyT4QdM2UldffuGApNrF8e938MwNpDCK9CPoyxrEoCgT+hObJc3mZa6lQ==
+"@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==
   dependencies:
-    "@babel/helper-function-name" "^7.10.4"
-    "@babel/helper-member-expression-to-functions" "^7.10.4"
-    "@babel/helper-optimise-call-expression" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
-    "@babel/helper-replace-supers" "^7.10.4"
-    "@babel/helper-split-export-declaration" "^7.10.4"
+    "@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-create-regexp-features-plugin@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.10.4.tgz#fdd60d88524659a0b6959c0579925e425714f3b8"
-  integrity sha512-2/hu58IEPKeoLF45DBwx3XFqsbCXmkdAay4spVr2x0jYgRxrSNp+ePwvSsy9g6YSaNDcKIQVPXk1Ov8S2edk2g==
+"@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.10.4"
-    "@babel/helper-regex" "^7.10.4"
-    regexpu-core "^4.7.0"
+    "@babel/helper-annotate-as-pure" "^7.12.13"
+    regexpu-core "^4.7.1"
 
-"@babel/helper-define-map@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.10.4.tgz#f037ad794264f729eda1889f4ee210b870999092"
-  integrity sha512-nIij0oKErfCnLUCWaCaHW0Bmtl2RO9cN7+u2QT8yqTywgALKlyUVOvHDElh+b5DwVC6YB1FOYFOTWcN/+41EDA==
+"@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==
   dependencies:
-    "@babel/helper-function-name" "^7.10.4"
-    "@babel/types" "^7.10.4"
-    lodash "^4.17.13"
+    "@babel/helper-compilation-targets" "^7.13.0"
+    "@babel/helper-module-imports" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/traverse" "^7.13.0"
+    debug "^4.1.1"
+    lodash.debounce "^4.0.8"
+    resolve "^1.14.2"
+    semver "^6.1.2"
 
-"@babel/helper-explode-assignable-expression@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.10.4.tgz#40a1cd917bff1288f699a94a75b37a1a2dbd8c7c"
-  integrity sha512-4K71RyRQNPRrR85sr5QY4X3VwG4wtVoXZB9+L3r1Gp38DhELyHCtovqydRi7c1Ovb17eRGiQ/FD5s8JdU0Uy5A==
+"@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/traverse" "^7.10.4"
-    "@babel/types" "^7.10.4"
+    "@babel/types" "^7.13.0"
 
-"@babel/helper-function-name@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz#d2d3b20c59ad8c47112fa7d2a94bc09d5ef82f1a"
-  integrity sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ==
+"@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.10.4"
-    "@babel/template" "^7.10.4"
-    "@babel/types" "^7.10.4"
+    "@babel/helper-get-function-arity" "^7.12.13"
+    "@babel/template" "^7.12.13"
+    "@babel/types" "^7.12.13"
 
-"@babel/helper-get-function-arity@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz#98c1cbea0e2332f33f9a4661b8ce1505b2c19ba2"
-  integrity sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A==
+"@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.10.4"
+    "@babel/types" "^7.12.13"
 
-"@babel/helper-hoist-variables@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.10.4.tgz#d49b001d1d5a68ca5e6604dda01a6297f7c9381e"
-  integrity sha512-wljroF5PgCk2juF69kanHVs6vrLwIPNp6DLD+Lrl3hoQ3PpPPikaDRNFA+0t81NOoMt2DL6WW/mdU8k4k6ZzuA==
+"@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==
   dependencies:
-    "@babel/types" "^7.10.4"
+    "@babel/traverse" "^7.13.0"
+    "@babel/types" "^7.13.0"
 
-"@babel/helper-member-expression-to-functions@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.10.4.tgz#7cd04b57dfcf82fce9aeae7d4e4452fa31b8c7c4"
-  integrity sha512-m5j85pK/KZhuSdM/8cHUABQTAslV47OjfIB9Cc7P+PvlAoBzdb79BGNfw8RhT5Mq3p+xGd0ZfAKixbrUZx0C7A==
+"@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==
   dependencies:
-    "@babel/types" "^7.10.4"
+    "@babel/types" "^7.13.12"
 
-"@babel/helper-module-imports@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz#4c5c54be04bd31670a7382797d75b9fa2e5b5620"
-  integrity sha512-nEQJHqYavI217oD9+s5MUBzk6x1IlvoS9WTPfgG43CbMEeStE0v+r+TucWdx8KFGowPGvyOkDT9+7DHedIDnVw==
+"@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.10.4"
+    "@babel/types" "^7.13.12"
 
-"@babel/helper-module-transforms@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.10.4.tgz#ca1f01fdb84e48c24d7506bb818c961f1da8805d"
-  integrity sha512-Er2FQX0oa3nV7eM1o0tNCTx7izmQtwAQsIiaLRWtavAAEcskb0XJ5OjJbVrYXWOTr8om921Scabn4/tzlx7j1Q==
+"@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.10.4"
-    "@babel/helper-replace-supers" "^7.10.4"
-    "@babel/helper-simple-access" "^7.10.4"
-    "@babel/helper-split-export-declaration" "^7.10.4"
-    "@babel/template" "^7.10.4"
-    "@babel/types" "^7.10.4"
-    lodash "^4.17.13"
+    "@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.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz#50dc96413d594f995a77905905b05893cd779673"
-  integrity sha512-n3UGKY4VXwXThEiKrgRAoVPBMqeoPgHVqiHZOanAJCG9nQUL2pLRQirUzl0ioKclHGpGqRgIOkgcIJaIWLpygg==
+"@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.10.4"
+    "@babel/types" "^7.12.13"
 
-"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.8.0":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375"
-  integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==
+"@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-regex@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.10.4.tgz#59b373daaf3458e5747dece71bbaf45f9676af6d"
-  integrity sha512-inWpnHGgtg5NOF0eyHlC0/74/VkdRITY9dtTpB2PrxKKn+AkVMRiZz/Adrx+Ssg+MLDesi2zohBW6MVq6b4pOQ==
+"@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:
-    lodash "^4.17.13"
+    "@babel/helper-annotate-as-pure" "^7.12.13"
+    "@babel/helper-wrap-function" "^7.13.0"
+    "@babel/types" "^7.13.0"
 
-"@babel/helper-remap-async-to-generator@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.10.4.tgz#fce8bea4e9690bbe923056ded21e54b4e8b68ed5"
-  integrity sha512-86Lsr6NNw3qTNl+TBcF1oRZMaVzJtbWTyTko+CQL/tvNvcGYEFKbLXDPxtW0HKk3McNOk4KzY55itGWCAGK5tg==
+"@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-annotate-as-pure" "^7.10.4"
-    "@babel/helper-wrap-function" "^7.10.4"
-    "@babel/template" "^7.10.4"
-    "@babel/traverse" "^7.10.4"
-    "@babel/types" "^7.10.4"
+    "@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-replace-supers@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.10.4.tgz#d585cd9388ea06e6031e4cd44b6713cbead9e6cf"
-  integrity sha512-sPxZfFXocEymYTdVK1UNmFPBN+Hv5mJkLPsYWwGBxZAxaWfFu+xqp7b6qWD0yjNuNL2VKc6L5M18tOXUP7NU0A==
+"@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==
   dependencies:
-    "@babel/helper-member-expression-to-functions" "^7.10.4"
-    "@babel/helper-optimise-call-expression" "^7.10.4"
-    "@babel/traverse" "^7.10.4"
-    "@babel/types" "^7.10.4"
+    "@babel/types" "^7.13.12"
 
-"@babel/helper-simple-access@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz#0f5ccda2945277a2a7a2d3a821e15395edcf3461"
-  integrity sha512-0fMy72ej/VEvF8ULmX6yb5MtHG4uH4Dbd6I/aHDb/JVg0bbivwt9Wg+h3uMvX+QSFtwr5MeItvazbrc4jtRAXw==
+"@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/template" "^7.10.4"
-    "@babel/types" "^7.10.4"
+    "@babel/types" "^7.12.1"
 
-"@babel/helper-split-export-declaration@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.4.tgz#2c70576eaa3b5609b24cb99db2888cc3fc4251d1"
-  integrity sha512-pySBTeoUff56fL5CBU2hWm9TesA4r/rOkI9DyJLvvgz09MB9YtfIYe3iBriVaYNaPe+Alua0vBIOVOLs2buWhg==
+"@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.10.4"
+    "@babel/types" "^7.12.13"
 
-"@babel/helper-validator-identifier@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2"
-  integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==
+"@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-wrap-function@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.10.4.tgz#8a6f701eab0ff39f765b5a1cfef409990e624b87"
-  integrity sha512-6py45WvEF0MhiLrdxtRjKjufwLL1/ob2qDJgg5JgNdojBAZSAKnAjkyOCNug6n+OBl4VW76XjvgSFTdaMcW0Ug==
+"@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.10.4"
-    "@babel/template" "^7.10.4"
-    "@babel/traverse" "^7.10.4"
-    "@babel/types" "^7.10.4"
+    "@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.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.10.4.tgz#2abeb0d721aff7c0a97376b9e1f6f65d7a475044"
-  integrity sha512-L2gX/XeUONeEbI78dXSrJzGdz4GQ+ZTA/aazfUsFaWjSe95kiCuOZ5HsXvkiw3iwF+mFHSRUfJU8t6YavocdXA==
+"@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.10.4"
-    "@babel/traverse" "^7.10.4"
-    "@babel/types" "^7.10.4"
+    "@babel/template" "^7.12.13"
+    "@babel/traverse" "^7.13.0"
+    "@babel/types" "^7.13.0"
 
-"@babel/highlight@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.10.4.tgz#7d1bdfd65753538fabe6c38596cdb76d9ac60143"
-  integrity sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==
+"@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==
   dependencies:
-    "@babel/helper-validator-identifier" "^7.10.4"
+    "@babel/helper-validator-identifier" "^7.12.11"
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.1.0", "@babel/parser@^7.10.4", "@babel/parser@^7.4.3":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.10.4.tgz#9eedf27e1998d87739fb5028a5120557c06a1a64"
-  integrity sha512-8jHII4hf+YVDsskTF6WuMB3X4Eh+PsUkC2ljq22so5rHvH+T8BzyL94VOdyFLNR8tBSVXOTbNHOKpR4TfRxVtA==
+"@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/plugin-proposal-async-generator-functions@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.10.4.tgz#4b65abb3d9bacc6c657aaa413e56696f9f170fc6"
-  integrity sha512-MJbxGSmejEFVOANAezdO39SObkURO5o/8b6fSH6D1pi9RZQt+ldppKPXfqgUWpSQ9asM6xaSaSJIaeWMDRP0Zg==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
-    "@babel/helper-remap-async-to-generator" "^7.10.4"
-    "@babel/plugin-syntax-async-generators" "^7.8.0"
+    "@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/plugin-proposal-class-properties@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.10.4.tgz#a33bf632da390a59c7a8c570045d1115cd778807"
-  integrity sha512-vhwkEROxzcHGNu2mzUC0OFFNXdZ4M23ib8aRRcJSsW8BZK9pQMD7QB7csl97NBbgGZO7ZyHUyKDnxzOaP4IrCg==
+"@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==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@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-dynamic-import@^7.10.4", "@babel/plugin-proposal-dynamic-import@^7.8.3":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.10.4.tgz#ba57a26cb98b37741e9d5bca1b8b0ddf8291f17e"
-  integrity sha512-up6oID1LeidOOASNXgv/CFbgBqTuKJ0cJjz6An5tWD+NVBNlp3VNSBxv2ZdU7SYl3NxJC7agAQDApZusV6uFwQ==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
-    "@babel/plugin-syntax-dynamic-import" "^7.8.0"
+    "@babel/helper-create-class-features-plugin" "^7.13.0"
+    "@babel/helper-plugin-utils" "^7.13.0"
 
-"@babel/plugin-proposal-json-strings@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.10.4.tgz#593e59c63528160233bd321b1aebe0820c2341db"
-  integrity sha512-fCL7QF0Jo83uy1K0P2YXrfX11tj3lkpN7l4dMv9Y9VkowkhkQDwFHFd8IiwyK5MZjE8UpbgokkgtcReH88Abaw==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
-    "@babel/plugin-syntax-json-strings" "^7.8.0"
+    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/plugin-syntax-dynamic-import" "^7.8.3"
 
-"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4", "@babel/plugin-proposal-nullish-coalescing-operator@^7.8.3":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.10.4.tgz#02a7e961fc32e6d5b2db0649e01bf80ddee7e04a"
-  integrity sha512-wq5n1M3ZUlHl9sqT2ok1T2/MTt6AXE0e1Lz4WzWBr95LsAZ5qDXe4KnFuauYyEyLiohvXFMdbsOTMyLZs91Zlw==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
-    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0"
+    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/plugin-syntax-export-namespace-from" "^7.8.3"
 
-"@babel/plugin-proposal-numeric-separator@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.10.4.tgz#ce1590ff0a65ad12970a609d78855e9a4c1aef06"
-  integrity sha512-73/G7QoRoeNkLZFxsoCCvlg4ezE4eM+57PnOqgaPOozd5myfj7p0muD1mRVJvbUWbOzD+q3No2bWbaKy+DJ8DA==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.13.0"
+    "@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==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.13.0"
+    "@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==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.13.0"
+    "@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==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.12.13"
     "@babel/plugin-syntax-numeric-separator" "^7.10.4"
 
-"@babel/plugin-proposal-object-rest-spread@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.10.4.tgz#50129ac216b9a6a55b3853fdd923e74bf553a4c0"
-  integrity sha512-6vh4SqRuLLarjgeOf4EaROJAHjvu9Gl+/346PbDH9yWbJyfnJ/ah3jmYKYtswEyCoWZiidvVHjHshd4WgjB9BA==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
-    "@babel/plugin-syntax-object-rest-spread" "^7.8.0"
-    "@babel/plugin-transform-parameters" "^7.10.4"
+    "@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-proposal-optional-catch-binding@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.10.4.tgz#31c938309d24a78a49d68fdabffaa863758554dd"
-  integrity sha512-LflT6nPh+GK2MnFiKDyLiqSqVHkQnVf7hdoAvyTnnKj9xB3docGRsdPuxp6qqqW19ifK3xgc9U5/FwrSaCNX5g==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
-    "@babel/plugin-syntax-optional-catch-binding" "^7.8.0"
+    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/plugin-syntax-optional-catch-binding" "^7.8.3"
 
-"@babel/plugin-proposal-optional-chaining@^7.10.4", "@babel/plugin-proposal-optional-chaining@^7.9.0":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.10.4.tgz#750f1255e930a1f82d8cdde45031f81a0d0adff7"
-  integrity sha512-ZIhQIEeavTgouyMSdZRap4VPPHqJJ3NEs2cuHs5p0erH+iz6khB0qfgU8g7UuJkG88+fBMy23ZiU+nuHvekJeQ==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
-    "@babel/plugin-syntax-optional-chaining" "^7.8.0"
+    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1"
+    "@babel/plugin-syntax-optional-chaining" "^7.8.3"
 
-"@babel/plugin-proposal-private-methods@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.10.4.tgz#b160d972b8fdba5c7d111a145fc8c421fc2a6909"
-  integrity sha512-wh5GJleuI8k3emgTg5KkJK6kHNsGEr0uBTDBuQUBJwckk9xs1ez79ioheEVVxMLyPscB0LfkbVHslQqIzWV6Bw==
+"@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==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-create-class-features-plugin" "^7.13.0"
+    "@babel/helper-plugin-utils" "^7.13.0"
 
-"@babel/plugin-proposal-unicode-property-regex@^7.10.4", "@babel/plugin-proposal-unicode-property-regex@^7.4.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.10.4.tgz#4483cda53041ce3413b7fe2f00022665ddfaa75d"
-  integrity sha512-H+3fOgPnEXFL9zGYtKQe4IDOPKYlZdF1kqFDQRRb8PK4B8af1vAGK04tF5iQAAsui+mHNBQSAtd2/ndEDe9wuA==
+"@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==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-create-regexp-features-plugin" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-syntax-async-generators@^7.8.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-class-properties@^7.10.4", "@babel/plugin-syntax-class-properties@^7.8.3":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.10.4.tgz#6644e6a0baa55a61f9e3231f6c9eeb6ee46c124c"
-  integrity sha512-GCSBF7iUle6rNugfURwNmCGG3Z/2+opxAMLs1nND4bhEG5PuxTIggDBoeYYSujAlLtsupzOHYJQgPS3pivwXIA==
+"@babel/plugin-syntax-class-properties@^7.12.13", "@babel/plugin-syntax-class-properties@^7.8.3":
+  version "7.12.13"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10"
+  integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-syntax-dynamic-import@^7.8.0", "@babel/plugin-syntax-dynamic-import@^7.8.3":
+"@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"
   integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==
   dependencies:
     "@babel/helper-plugin-utils" "^7.8.0"
 
-"@babel/plugin-syntax-import-meta@^7.8.3":
+"@babel/plugin-syntax-export-namespace-from@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz#028964a9ba80dbc094c915c487ad7c4e7a66465a"
+  integrity sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
+
+"@babel/plugin-syntax-import-meta@^7.10.4":
   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-json-strings@^7.8.0":
+"@babel/plugin-syntax-json-strings@^7.8.3":
   version "7.8.3"
   resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a"
   integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==
   dependencies:
     "@babel/helper-plugin-utils" "^7.8.0"
 
-"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.0", "@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3":
+"@babel/plugin-syntax-logical-assignment-operators@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699"
+  integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3":
   version "7.8.3"
   resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9"
   integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==
@@ -396,359 +437,361 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-syntax-object-rest-spread@^7.8.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-syntax-optional-catch-binding@^7.8.0":
+"@babel/plugin-syntax-optional-catch-binding@^7.8.3":
   version "7.8.3"
   resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1"
   integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==
   dependencies:
     "@babel/helper-plugin-utils" "^7.8.0"
 
-"@babel/plugin-syntax-optional-chaining@^7.8.0", "@babel/plugin-syntax-optional-chaining@^7.8.3":
+"@babel/plugin-syntax-optional-chaining@^7.8.3":
   version "7.8.3"
   resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a"
   integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==
   dependencies:
     "@babel/helper-plugin-utils" "^7.8.0"
 
-"@babel/plugin-syntax-top-level-await@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.10.4.tgz#4bbeb8917b54fcf768364e0a81f560e33a3ef57d"
-  integrity sha512-ni1brg4lXEmWyafKr0ccFWkJG0CeMt4WV1oyeBW6EFObF4oOHclbkj5cARxAPQyAQ2UTuplJyK4nfkXIMMFvsQ==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-arrow-functions@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.10.4.tgz#e22960d77e697c74f41c501d44d73dbf8a6a64cd"
-  integrity sha512-9J/oD1jV0ZCBcgnoFWFq1vJd4msoKb/TCpGNFyyLt0zABdcvgK3aYikZ8HjzB14c26bc7E3Q1yugpwGy2aTPNA==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.13.0"
 
-"@babel/plugin-transform-async-to-generator@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.10.4.tgz#41a5017e49eb6f3cda9392a51eef29405b245a37"
-  integrity sha512-F6nREOan7J5UXTLsDsZG3DXmZSVofr2tGNwfdrVwkDWHfQckbQXnXSPfD7iO+c/2HGqycwyLST3DnZ16n+cBJQ==
+"@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==
   dependencies:
-    "@babel/helper-module-imports" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
-    "@babel/helper-remap-async-to-generator" "^7.10.4"
+    "@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.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.10.4.tgz#1afa595744f75e43a91af73b0d998ecfe4ebc2e8"
-  integrity sha512-WzXDarQXYYfjaV1szJvN3AD7rZgZzC1JtjJZ8dMHUyiK8mxPRahynp14zzNjU3VkPqPsO38CzxiWO1c9ARZ8JA==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-block-scoping@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.10.4.tgz#a670d1364bb5019a621b9ea2001482876d734787"
-  integrity sha512-J3b5CluMg3hPUii2onJDRiaVbPtKFPLEaV5dOPY5OeAbDi1iU/UbbFFTgwb7WnanaDy7bjU35kc26W3eM5Qa0A==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
-    lodash "^4.17.13"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-classes@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.10.4.tgz#405136af2b3e218bc4a1926228bc917ab1a0adc7"
-  integrity sha512-2oZ9qLjt161dn1ZE0Ms66xBncQH4In8Sqw1YWgBUZuGVJJS5c0OFZXL6dP2MRHrkU/eKhWg8CzFJhRQl50rQxA==
+"@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==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.10.4"
-    "@babel/helper-define-map" "^7.10.4"
-    "@babel/helper-function-name" "^7.10.4"
-    "@babel/helper-optimise-call-expression" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
-    "@babel/helper-replace-supers" "^7.10.4"
-    "@babel/helper-split-export-declaration" "^7.10.4"
+    "@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.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.10.4.tgz#9ded83a816e82ded28d52d4b4ecbdd810cdfc0eb"
-  integrity sha512-JFwVDXcP/hM/TbyzGq3l/XWGut7p46Z3QvqFMXTfk6/09m7xZHJUN9xHfsv7vqqD4YnfI5ueYdSJtXqqBLyjBw==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.13.0"
 
-"@babel/plugin-transform-destructuring@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.10.4.tgz#70ddd2b3d1bea83d01509e9bb25ddb3a74fc85e5"
-  integrity sha512-+WmfvyfsyF603iPa6825mq6Qrb7uLjTOsa3XOFzlYcYDHSS4QmpOWOL0NNBY5qMbvrcf3tq0Cw+v4lxswOBpgA==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.13.0"
 
-"@babel/plugin-transform-dotall-regex@^7.10.4", "@babel/plugin-transform-dotall-regex@^7.4.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.10.4.tgz#469c2062105c1eb6a040eaf4fac4b488078395ee"
-  integrity sha512-ZEAVvUTCMlMFAbASYSVQoxIbHm2OkG2MseW6bV2JjIygOjdVv8tuxrCTzj1+Rynh7ODb8GivUy7dzEXzEhuPaA==
+"@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==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-create-regexp-features-plugin" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-duplicate-keys@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.10.4.tgz#697e50c9fee14380fe843d1f306b295617431e47"
-  integrity sha512-GL0/fJnmgMclHiBTTWXNlYjYsA7rDrtsazHG6mglaGSTh0KsrW04qml+Bbz9FL0LcJIRwBWL5ZqlNHKTkU3xAA==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-exponentiation-operator@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.10.4.tgz#5ae338c57f8cf4001bdb35607ae66b92d665af2e"
-  integrity sha512-S5HgLVgkBcRdyQAHbKj+7KyuWx8C6t5oETmUuwz1pt3WTWJhsUV0WIIXuVvfXMxl/QQyHKlSCNNtaIamG8fysw==
+"@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==
   dependencies:
-    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-for-of@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.10.4.tgz#c08892e8819d3a5db29031b115af511dbbfebae9"
-  integrity sha512-ItdQfAzu9AlEqmusA/65TqJ79eRcgGmpPPFvBnGILXZH975G0LNjP1yjHvGgfuCxqrPPueXOPe+FsvxmxKiHHQ==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.13.0"
 
-"@babel/plugin-transform-function-name@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.10.4.tgz#6a467880e0fc9638514ba369111811ddbe2644b7"
-  integrity sha512-OcDCq2y5+E0dVD5MagT5X+yTRbcvFjDI2ZVAottGH6tzqjx/LKpgkUepu3hp/u4tZBzxxpNGwLsAvGBvQ2mJzg==
+"@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==
   dependencies:
-    "@babel/helper-function-name" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-function-name" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-literals@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.10.4.tgz#9f42ba0841100a135f22712d0e391c462f571f3c"
-  integrity sha512-Xd/dFSTEVuUWnyZiMu76/InZxLTYilOSr1UlHV+p115Z/Le2Fi1KXkJUYz0b42DfndostYlPub3m8ZTQlMaiqQ==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-member-expression-literals@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.10.4.tgz#b1ec44fcf195afcb8db2c62cd8e551c881baf8b7"
-  integrity sha512-0bFOvPyAoTBhtcJLr9VcwZqKmSjFml1iVxvPL0ReomGU53CX53HsM4h2SzckNdkQcHox1bpAqzxBI1Y09LlBSw==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-modules-amd@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.10.4.tgz#cb407c68b862e4c1d13a2fc738c7ec5ed75fc520"
-  integrity sha512-3Fw+H3WLUrTlzi3zMiZWp3AR4xadAEMv6XRCYnd5jAlLM61Rn+CRJaZMaNvIpcJpQ3vs1kyifYvEVPFfoSkKOA==
+"@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==
   dependencies:
-    "@babel/helper-module-transforms" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@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-modules-commonjs@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.10.4.tgz#66667c3eeda1ebf7896d41f1f16b17105a2fbca0"
-  integrity sha512-Xj7Uq5o80HDLlW64rVfDBhao6OX89HKUmb+9vWYaLXBZOma4gA6tw4Ni1O5qVDoZWUV0fxMYA0aYzOawz0l+1w==
+"@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==
   dependencies:
-    "@babel/helper-module-transforms" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
-    "@babel/helper-simple-access" "^7.10.4"
+    "@babel/helper-module-transforms" "^7.13.0"
+    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-simple-access" "^7.12.13"
     babel-plugin-dynamic-import-node "^2.3.3"
 
-"@babel/plugin-transform-modules-systemjs@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.10.4.tgz#8f576afd943ac2f789b35ded0a6312f929c633f9"
-  integrity sha512-Tb28LlfxrTiOTGtZFsvkjpyjCl9IoaRI52AEU/VIwOwvDQWtbNJsAqTXzh+5R7i74e/OZHH2c2w2fsOqAfnQYQ==
+"@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==
   dependencies:
-    "@babel/helper-hoist-variables" "^7.10.4"
-    "@babel/helper-module-transforms" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@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-plugin-dynamic-import-node "^2.3.3"
 
-"@babel/plugin-transform-modules-umd@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.10.4.tgz#9a8481fe81b824654b3a0b65da3df89f3d21839e"
-  integrity sha512-mohW5q3uAEt8T45YT7Qc5ws6mWgJAaL/8BfWD9Dodo1A3RKWli8wTS+WiQ/knF+tXlPirW/1/MqzzGfCExKECA==
+"@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==
   dependencies:
-    "@babel/helper-module-transforms" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-module-transforms" "^7.13.0"
+    "@babel/helper-plugin-utils" "^7.13.0"
 
-"@babel/plugin-transform-named-capturing-groups-regex@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.10.4.tgz#78b4d978810b6f3bcf03f9e318f2fc0ed41aecb6"
-  integrity sha512-V6LuOnD31kTkxQPhKiVYzYC/Jgdq53irJC/xBSmqcNcqFGV+PER4l6rU5SH2Vl7bH9mLDHcc0+l9HUOe4RNGKA==
+"@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==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.10.4"
+    "@babel/helper-create-regexp-features-plugin" "^7.12.13"
 
-"@babel/plugin-transform-new-target@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.10.4.tgz#9097d753cb7b024cb7381a3b2e52e9513a9c6888"
-  integrity sha512-YXwWUDAH/J6dlfwqlWsztI2Puz1NtUAubXhOPLQ5gjR/qmQ5U96DY4FQO8At33JN4XPBhrjB8I4eMmLROjjLjw==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-object-super@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.10.4.tgz#d7146c4d139433e7a6526f888c667e314a093894"
-  integrity sha512-5iTw0JkdRdJvr7sY0vHqTpnruUpTea32JHmq/atIWqsnNussbRzjEDyWep8UNztt1B5IusBYg8Irb0bLbiEBCQ==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
-    "@babel/helper-replace-supers" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-replace-supers" "^7.12.13"
 
-"@babel/plugin-transform-parameters@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.10.4.tgz#7b4d137c87ea7adc2a0f3ebf53266871daa6fced"
-  integrity sha512-RurVtZ/D5nYfEg0iVERXYKEgDFeesHrHfx8RT05Sq57ucj2eOYAP6eu5fynL4Adju4I/mP/I6SO0DqNWAXjfLQ==
+"@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-get-function-arity" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.13.0"
 
-"@babel/plugin-transform-property-literals@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.10.4.tgz#f6fe54b6590352298785b83edd815d214c42e3c0"
-  integrity sha512-ofsAcKiUxQ8TY4sScgsGeR2vJIsfrzqvFb9GvJ5UdXDzl+MyYCaBj/FGzXuv7qE0aJcjWMILny1epqelnFlz8g==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-regenerator@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.10.4.tgz#2015e59d839074e76838de2159db421966fd8b63"
-  integrity sha512-3thAHwtor39A7C04XucbMg17RcZ3Qppfxr22wYzZNcVIkPHfpM9J0SO8zuCV6SZa265kxBJSrfKTvDCYqBFXGw==
+"@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==
   dependencies:
     regenerator-transform "^0.14.2"
 
-"@babel/plugin-transform-reserved-words@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.10.4.tgz#8f2682bcdcef9ed327e1b0861585d7013f8a54dd"
-  integrity sha512-hGsw1O6Rew1fkFbDImZIEqA8GoidwTAilwCyWqLBM9f+e/u/sQMQu7uX6dyokfOayRuuVfKOW4O7HvaBWM+JlQ==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-shorthand-properties@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.10.4.tgz#9fd25ec5cdd555bb7f473e5e6ee1c971eede4dd6"
-  integrity sha512-AC2K/t7o07KeTIxMoHneyX90v3zkm5cjHJEokrPEAGEy3UCp8sLKfnfOIGdZ194fyN4wfX/zZUWT9trJZ0qc+Q==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-spread@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.10.4.tgz#4e2c85ea0d6abaee1b24dcfbbae426fe8d674cff"
-  integrity sha512-1e/51G/Ni+7uH5gktbWv+eCED9pP8ZpRhZB3jOaI3mmzfvJTWHkuyYTv0Z5PYtyM+Tr2Ccr9kUdQxn60fI5WuQ==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1"
 
-"@babel/plugin-transform-sticky-regex@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.10.4.tgz#8f3889ee8657581130a29d9cc91d7c73b7c4a28d"
-  integrity sha512-Ddy3QZfIbEV0VYcVtFDCjeE4xwVTJWTmUtorAJkn6u/92Z/nWJNV+mILyqHKrUxXYKA2EoCilgoPePymKL4DvQ==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
-    "@babel/helper-regex" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-template-literals@^7.10.4", "@babel/plugin-transform-template-literals@^7.8.3":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.10.4.tgz#e6375407b30fcb7fcfdbba3bb98ef3e9d36df7bc"
-  integrity sha512-4NErciJkAYe+xI5cqfS8pV/0ntlY5N5Ske/4ImxAVX7mk9Rxt2bwDTGv1Msc2BRJvWQcmYEC+yoMLdX22aE4VQ==
+"@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==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.13.0"
 
-"@babel/plugin-transform-typeof-symbol@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.10.4.tgz#9509f1a7eec31c4edbffe137c16cc33ff0bc5bfc"
-  integrity sha512-QqNgYwuuW0y0H+kUE/GWSR45t/ccRhe14Fs/4ZRouNNQsyd4o3PG4OtHiIrepbM2WKUBDAXKCAK/Lk4VhzTaGA==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-unicode-escapes@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.10.4.tgz#feae523391c7651ddac115dae0a9d06857892007"
-  integrity sha512-y5XJ9waMti2J+e7ij20e+aH+fho7Wb7W8rNuu72aKRwCHFqQdhkdU2lo3uZ9tQuboEJcUFayXdARhcxLQ3+6Fg==
+"@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==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-unicode-regex@^7.10.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.10.4.tgz#e56d71f9282fac6db09c82742055576d5e6d80a8"
-  integrity sha512-wNfsc4s8N2qnIwpO/WP2ZiSyjfpTamT2C9V9FDH/Ljub9zw6P3SjkXcFmc0RQUt96k2fmIvtla2MMjgTwIAC+A==
+"@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==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-create-regexp-features-plugin" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
 "@babel/preset-env@^7.9.0":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.10.4.tgz#fbf57f9a803afd97f4f32e4f798bb62e4b2bef5f"
-  integrity sha512-tcmuQ6vupfMZPrLrc38d0sF2OjLT3/bZ0dry5HchNCQbrokoQi4reXqclvkkAT5b+gWc23meVWpve5P/7+w/zw==
+  version "7.13.12"
+  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.13.12.tgz#6dff470478290582ac282fb77780eadf32480237"
+  integrity sha512-JzElc6jk3Ko6zuZgBtjOd01pf9yYDEIH8BcqVuYIuOkzOwDesoa/Nz4gIo4lBG6K861KTV9TvIgmFuT6ytOaAA==
   dependencies:
-    "@babel/compat-data" "^7.10.4"
-    "@babel/helper-compilation-targets" "^7.10.4"
-    "@babel/helper-module-imports" "^7.10.4"
-    "@babel/helper-plugin-utils" "^7.10.4"
-    "@babel/plugin-proposal-async-generator-functions" "^7.10.4"
-    "@babel/plugin-proposal-class-properties" "^7.10.4"
-    "@babel/plugin-proposal-dynamic-import" "^7.10.4"
-    "@babel/plugin-proposal-json-strings" "^7.10.4"
-    "@babel/plugin-proposal-nullish-coalescing-operator" "^7.10.4"
-    "@babel/plugin-proposal-numeric-separator" "^7.10.4"
-    "@babel/plugin-proposal-object-rest-spread" "^7.10.4"
-    "@babel/plugin-proposal-optional-catch-binding" "^7.10.4"
-    "@babel/plugin-proposal-optional-chaining" "^7.10.4"
-    "@babel/plugin-proposal-private-methods" "^7.10.4"
-    "@babel/plugin-proposal-unicode-property-regex" "^7.10.4"
-    "@babel/plugin-syntax-async-generators" "^7.8.0"
-    "@babel/plugin-syntax-class-properties" "^7.10.4"
-    "@babel/plugin-syntax-dynamic-import" "^7.8.0"
-    "@babel/plugin-syntax-json-strings" "^7.8.0"
-    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0"
+    "@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/plugin-syntax-async-generators" "^7.8.4"
+    "@babel/plugin-syntax-class-properties" "^7.12.13"
+    "@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"
+    "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4"
+    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
     "@babel/plugin-syntax-numeric-separator" "^7.10.4"
-    "@babel/plugin-syntax-object-rest-spread" "^7.8.0"
-    "@babel/plugin-syntax-optional-catch-binding" "^7.8.0"
-    "@babel/plugin-syntax-optional-chaining" "^7.8.0"
-    "@babel/plugin-syntax-top-level-await" "^7.10.4"
-    "@babel/plugin-transform-arrow-functions" "^7.10.4"
-    "@babel/plugin-transform-async-to-generator" "^7.10.4"
-    "@babel/plugin-transform-block-scoped-functions" "^7.10.4"
-    "@babel/plugin-transform-block-scoping" "^7.10.4"
-    "@babel/plugin-transform-classes" "^7.10.4"
-    "@babel/plugin-transform-computed-properties" "^7.10.4"
-    "@babel/plugin-transform-destructuring" "^7.10.4"
-    "@babel/plugin-transform-dotall-regex" "^7.10.4"
-    "@babel/plugin-transform-duplicate-keys" "^7.10.4"
-    "@babel/plugin-transform-exponentiation-operator" "^7.10.4"
-    "@babel/plugin-transform-for-of" "^7.10.4"
-    "@babel/plugin-transform-function-name" "^7.10.4"
-    "@babel/plugin-transform-literals" "^7.10.4"
-    "@babel/plugin-transform-member-expression-literals" "^7.10.4"
-    "@babel/plugin-transform-modules-amd" "^7.10.4"
-    "@babel/plugin-transform-modules-commonjs" "^7.10.4"
-    "@babel/plugin-transform-modules-systemjs" "^7.10.4"
-    "@babel/plugin-transform-modules-umd" "^7.10.4"
-    "@babel/plugin-transform-named-capturing-groups-regex" "^7.10.4"
-    "@babel/plugin-transform-new-target" "^7.10.4"
-    "@babel/plugin-transform-object-super" "^7.10.4"
-    "@babel/plugin-transform-parameters" "^7.10.4"
-    "@babel/plugin-transform-property-literals" "^7.10.4"
-    "@babel/plugin-transform-regenerator" "^7.10.4"
-    "@babel/plugin-transform-reserved-words" "^7.10.4"
-    "@babel/plugin-transform-shorthand-properties" "^7.10.4"
-    "@babel/plugin-transform-spread" "^7.10.4"
-    "@babel/plugin-transform-sticky-regex" "^7.10.4"
-    "@babel/plugin-transform-template-literals" "^7.10.4"
-    "@babel/plugin-transform-typeof-symbol" "^7.10.4"
-    "@babel/plugin-transform-unicode-escapes" "^7.10.4"
-    "@babel/plugin-transform-unicode-regex" "^7.10.4"
-    "@babel/preset-modules" "^0.1.3"
-    "@babel/types" "^7.10.4"
-    browserslist "^4.12.0"
-    core-js-compat "^3.6.2"
-    invariant "^2.2.2"
-    levenary "^1.1.1"
-    semver "^5.5.0"
+    "@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/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"
+    semver "^6.3.0"
 
-"@babel/preset-modules@^0.1.3":
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.3.tgz#13242b53b5ef8c883c3cf7dddd55b36ce80fbc72"
-  integrity sha512-Ra3JXOHBq2xd56xSF7lMKXdjBn3T772Y1Wet3yWnkDly9zHvJki029tAFzvAAK5cf4YV3yoxuP61crYRol6SVg==
+"@babel/preset-modules@^0.1.4":
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.4.tgz#362f2b68c662842970fdb5e254ffc8fc1c2e415e"
+  integrity sha512-J36NhwnfdzpmH41M1DrnkkgAqhZaqr/NBdPfQ677mLzlaXo+oDiv1deyCDtgAhz8p328otdob0Du7+xgHGZbKg==
   dependencies:
     "@babel/helper-plugin-utils" "^7.0.0"
     "@babel/plugin-proposal-unicode-property-regex" "^7.4.4"
@@ -757,43 +800,42 @@
     esutils "^2.0.2"
 
 "@babel/runtime@^7.8.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.4.tgz#a6724f1a6b8d2f6ea5236dbfe58c7d7ea9c5eb99"
-  integrity sha512-UpTN5yUJr9b4EX2CnGNWIvER7Ab83ibv0pcvvHc4UOdrBI5jb8bj+32cCwPX6xu0mt2daFNjYhoi+X7beH0RSw==
+  version "7.13.10"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.10.tgz#47d42a57b6095f4468da440388fdbad8bebf0d7d"
+  integrity sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw==
   dependencies:
     regenerator-runtime "^0.13.4"
 
-"@babel/template@^7.10.4", "@babel/template@^7.4.0":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278"
-  integrity sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA==
+"@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==
   dependencies:
-    "@babel/code-frame" "^7.10.4"
-    "@babel/parser" "^7.10.4"
-    "@babel/types" "^7.10.4"
+    "@babel/code-frame" "^7.12.13"
+    "@babel/parser" "^7.12.13"
+    "@babel/types" "^7.12.13"
 
-"@babel/traverse@^7.10.4", "@babel/traverse@^7.4.3":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.10.4.tgz#e642e5395a3b09cc95c8e74a27432b484b697818"
-  integrity sha512-aSy7p5THgSYm4YyxNGz6jZpXf+Ok40QF3aA2LyIONkDHpAcJzDUqlCKXv6peqYUs2gmic849C/t2HKw2a2K20Q==
+"@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==
   dependencies:
-    "@babel/code-frame" "^7.10.4"
-    "@babel/generator" "^7.10.4"
-    "@babel/helper-function-name" "^7.10.4"
-    "@babel/helper-split-export-declaration" "^7.10.4"
-    "@babel/parser" "^7.10.4"
-    "@babel/types" "^7.10.4"
+    "@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"
     debug "^4.1.0"
     globals "^11.1.0"
-    lodash "^4.17.13"
 
-"@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.4.4":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.10.4.tgz#369517188352e18219981efd156bfdb199fff1ee"
-  integrity sha512-UTCFOxC3FsFHb7lkRMVvgLzaRVamXuAs2Tz4wajva4WxtVY82eZeaUBtC2Zt95FU9TiznuC0Zk35tsim8jeVpg==
+"@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==
   dependencies:
-    "@babel/helper-validator-identifier" "^7.10.4"
-    lodash "^4.17.13"
+    "@babel/helper-validator-identifier" "^7.12.11"
+    lodash "^4.17.19"
     to-fast-properties "^2.0.0"
 
 "@koa/cors@^3.1.0":
@@ -803,48 +845,48 @@
   dependencies:
     vary "^1.1.2"
 
-"@open-wc/building-utils@^2.18.0":
-  version "2.18.0"
-  resolved "https://registry.yarnpkg.com/@open-wc/building-utils/-/building-utils-2.18.0.tgz#f80929dfcfb6d8a6cb5c933654c721808b4bb2d3"
-  integrity sha512-U1n8sLQlLt3IuqhU7tDsGQAGUfVMiB64xJsAmJEtekposrjqkjtRLU/WipvROl1A2GTsrMojMjNbFqzJghpd6g==
+"@open-wc/building-utils@^2.18.0", "@open-wc/building-utils@^2.18.3":
+  version "2.18.4"
+  resolved "https://registry.yarnpkg.com/@open-wc/building-utils/-/building-utils-2.18.4.tgz#397e42039f5d26c38f7a2cc01e347e0e5c2e8e99"
+  integrity sha512-wjNp9oE1SFsiBEqaI67ff60KHDpDbGMNF+82pvCHe412SFY4q8DNy8A+hesj1nZsuZHH1/olDfzBDbYKAnmgMg==
   dependencies:
-    "@babel/core" "^7.9.0"
+    "@babel/core" "^7.11.1"
     "@babel/plugin-syntax-dynamic-import" "^7.8.3"
-    "@webcomponents/shadycss" "^1.9.4"
-    "@webcomponents/webcomponentsjs" "^2.4.0"
+    "@webcomponents/shadycss" "^1.10.2"
+    "@webcomponents/webcomponentsjs" "^2.5.0"
     arrify "^2.0.1"
-    browserslist "^4.9.1"
-    chokidar "^3.0.0"
-    clean-css "^4.2.1"
+    browserslist "^4.16.0"
+    chokidar "^3.4.3"
+    clean-css "^4.2.3"
     clone "^2.1.2"
-    core-js-bundle "^3.6.0"
+    core-js-bundle "^3.8.1"
     deepmerge "^4.2.2"
-    es-module-shims "^0.4.6"
-    html-minifier "^4.0.0"
+    es-module-shims "^0.4.7"
+    html-minifier-terser "^5.1.1"
     lru-cache "^5.1.1"
     minimatch "^3.0.4"
     parse5 "^5.1.1"
     path-is-inside "^1.0.2"
-    regenerator-runtime "^0.13.3"
-    resolve "^1.11.1"
+    regenerator-runtime "^0.13.7"
+    resolve "^1.19.0"
     rimraf "^3.0.2"
     shady-css-scoped-element "^0.0.2"
-    systemjs "^6.3.1"
+    systemjs "^6.8.3"
     terser "^4.6.7"
     valid-url "^1.0.9"
-    whatwg-fetch "^3.0.0"
-    whatwg-url "^7.0.0"
+    whatwg-fetch "^3.5.0"
+    whatwg-url "^7.1.0"
 
 "@open-wc/karma-esm@^2.16.16":
-  version "2.16.16"
-  resolved "https://registry.yarnpkg.com/@open-wc/karma-esm/-/karma-esm-2.16.16.tgz#6ebff57f249e95f777b7e04782ef08ed41e22f53"
-  integrity sha512-IALT10JfwK+h7T0hGKTUliGdkWzQbyQg195D+RfUteIoTof6Z5+dBp7JUh2fQygIyNj7IIYHJ9ej816QlgHjdA==
+  version "2.16.18"
+  resolved "https://registry.yarnpkg.com/@open-wc/karma-esm/-/karma-esm-2.16.18.tgz#01f3f8c694d7b8dd3aef3159659f3fa9d3090c44"
+  integrity sha512-K+HeXqRdvOupnlRr7rHgBFSqgyr5E0KNS89BTnHN0qKcDpa+M+m1sZHInPrB+iFqLQ7hhWNeGmtesDFfnIBhaQ==
   dependencies:
     "@open-wc/building-utils" "^2.18.0"
     babel-plugin-istanbul "^5.1.4"
     chokidar "^3.0.0"
     deepmerge "^4.2.2"
-    es-dev-server "^1.56.0"
+    es-dev-server "^1.57.0"
     minimatch "^3.0.4"
     node-fetch "^2.6.0"
     polyfills-loader "^1.6.1"
@@ -859,9 +901,9 @@
     "@polymer/polymer" "^3.0.0"
 
 "@polymer/polymer@^3.0.0":
-  version "3.3.1"
-  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.3.1.tgz#9ad48992d2a96775f80b0673f3a615d6df8a3dfc"
-  integrity sha512-8KaB48tzyMjdsHdxo5KvCAaqmTe7rYDzQAoj/pyEfq9Fp4YfUaS+/xqwYj0GbiDAUNzwkmEQ7dw9cgnRNdKO8A==
+  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"
 
@@ -890,10 +932,10 @@
     estree-walker "^1.0.1"
     picomatch "^2.2.2"
 
-"@sinonjs/commons@^1", "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.7.2":
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.0.tgz#c8d68821a854c555bba172f3b06959a0039b236d"
-  integrity sha512-wEj54PfsZ5jGSwMX68G8ZXFawcSglQSXqCftWX3ec8MDUzQdHgcKvw97awHbY0efQEL5iKUOAmmVtoYgmrSG4Q==
+"@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==
   dependencies:
     type-detect "4.0.8"
 
@@ -904,18 +946,17 @@
   dependencies:
     "@sinonjs/commons" "^1.7.0"
 
-"@sinonjs/formatio@^5.0.1":
-  version "5.0.1"
-  resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-5.0.1.tgz#f13e713cb3313b1ab965901b01b0828ea6b77089"
-  integrity sha512-KaiQ5pBf1MpS09MuA0kp6KBQt2JUOQycqVG1NZXvzeaXe5LGFqAKueIS0bw4w0P9r7KuBSVdUk5QjXsUdu2CxQ==
+"@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"
-    "@sinonjs/samsam" "^5.0.2"
+    "@sinonjs/commons" "^1.7.0"
 
-"@sinonjs/samsam@^5.0.2", "@sinonjs/samsam@^5.0.3":
-  version "5.0.3"
-  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-5.0.3.tgz#86f21bdb3d52480faf0892a480c9906aa5a52938"
-  integrity sha512-QucHkc2uMJ0pFGjJUDP3F9dq5dx8QIaqISl9QgwLOh6P9yv877uONPGXh/OH/0zmM3tW1JjuJltAZV2l7zU+uQ==
+"@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==
   dependencies:
     "@sinonjs/commons" "^1.6.0"
     lodash.get "^4.4.2"
@@ -934,9 +975,9 @@
     "@types/node" "*"
 
 "@types/babel__core@^7.1.3":
-  version "7.1.9"
-  resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.9.tgz#77e59d438522a6fb898fa43dc3455c6e72f3963d"
-  integrity sha512-sY2RsIJ5rpER1u3/aQ8OFSI7qGIy8o1NEEbgb2UaJcvOtXOMpd39ko723NBpjQFg9SIX7TXtjejZVGeIMLhoOw==
+  version "7.1.14"
+  resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.14.tgz#faaeefc4185ec71c389f4501ee5ec84b170cc402"
+  integrity sha512-zGZJzzBUVDo/eV6KgbE0f0ZI7dInEYvo12Rb70uNQDshC3SkRMb67ja0GgRHZgAX3Za6rhaWlvbDO8rrGyAb1g==
   dependencies:
     "@babel/parser" "^7.1.0"
     "@babel/types" "^7.0.0"
@@ -945,24 +986,24 @@
     "@types/babel__traverse" "*"
 
 "@types/babel__generator@*":
-  version "7.6.1"
-  resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.1.tgz#4901767b397e8711aeb99df8d396d7ba7b7f0e04"
-  integrity sha512-bBKm+2VPJcMRVwNhxKu8W+5/zT7pwNEqeokFOmbvVSqGzFneNxYcEBro9Ac7/N9tlsaPYnZLK8J1LWKkMsLAew==
+  version "7.6.2"
+  resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.2.tgz#f3d71178e187858f7c45e30380f8f1b7415a12d8"
+  integrity sha512-MdSJnBjl+bdwkLskZ3NGFp9YcXGx5ggLpQQPqtgakVhsWK0hTtNYhjpZLlWQTviGTvF8at+Bvli3jV7faPdgeQ==
   dependencies:
     "@babel/types" "^7.0.0"
 
 "@types/babel__template@*":
-  version "7.0.2"
-  resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.0.2.tgz#4ff63d6b52eddac1de7b975a5223ed32ecea9307"
-  integrity sha512-/K6zCpeW7Imzgab2bLkLEbz0+1JlFSrUMdw7KoIIu+IUdu51GWaBZpd3y1VXGVXzynvGa4DaIaxNZHiON3GXUg==
+  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==
   dependencies:
     "@babel/parser" "^7.1.0"
     "@babel/types" "^7.0.0"
 
 "@types/babel__traverse@*":
-  version "7.0.12"
-  resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.0.12.tgz#22f49a028e69465390f87bb103ebd61bd086b8f5"
-  integrity sha512-t4CoEokHTfcyfb4hUaF9oOHu9RmmNWnm1CP0YmMqOOfClKascOmvlEM736vlqeScuGvBDsHkf8R2INd4DWreQA==
+  version "7.11.1"
+  resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.11.1.tgz#654f6c4f67568e24c23b367e947098c6206fa639"
+  integrity sha512-Vs0hm0vPahPMYi9tDjtP66llufgO3ST16WXaSTtDGEl9cewAl3AibmxWw6TINOqHPT9z0uABKAYjT9jNSg4npw==
   dependencies:
     "@babel/types" "^7.3.0"
 
@@ -975,24 +1016,26 @@
     "@types/node" "*"
 
 "@types/browserslist-useragent@^3.0.0":
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/@types/browserslist-useragent/-/browserslist-useragent-3.0.0.tgz#d425c9818182ce71ce53866798cee9c7d41d6e53"
-  integrity sha512-ZBvKzg3yyWNYEkwxAzdmUzp27sFvw+1m080/+2lwrt+eltNefn1f4fnpMyrjOla31p8zLleCYqQXw+3EETfn0w==
+  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==
 
 "@types/browserslist@^4.8.0":
-  version "4.8.0"
-  resolved "https://registry.yarnpkg.com/@types/browserslist/-/browserslist-4.8.0.tgz#60489aefdf0fcb56c2d8eb65267ff08dad7a526d"
-  integrity sha512-4PyO9OM08APvxxo1NmQyQKlJdowPCOQIy5D/NLO3aO0vGC57wsMptvGp3b8IbYnupFZr92l1dlVief1JvS6STQ==
+  version "4.15.0"
+  resolved "https://registry.yarnpkg.com/@types/browserslist/-/browserslist-4.15.0.tgz#ba0265b33003a2581df1fc5f483321a30205f2d2"
+  integrity sha512-h9LyKErRGZqMsHh9bd+FE8yCIal4S0DxKTOeui56VgVXqa66TKiuaIUxCAI7c1O0LjaUzOTcsMyOpO9GetozRA==
+  dependencies:
+    browserslist "*"
 
 "@types/caniuse-api@^3.0.0":
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.0.tgz#af31cc52062be0ab24583be072fd49b634dcc2fe"
-  integrity sha512-wT1VfnScjAftZsvLYaefu/UuwYJdYBwD2JDL2OQd01plGmuAoir5V6HnVHgrfh7zEwcasoiyO2wQ+W58sNh2sw==
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.1.tgz#94c0073a2c305d0476ce9199cd6ef3fd7d2c5f66"
+  integrity sha512-VcjPciJLx86btwWypSo6vRzZSOC6asS3/SGgn7r7Xk7jAWNyMoxCy+IQzI2vuW2Bvs3iytyOEwsjLJKmHXBvmA==
 
-"@types/chai@^4.2.14":
-  version "4.2.14"
-  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.14.tgz#44d2dd0b5de6185089375d976b4ec5caf6861193"
-  integrity sha512-G+ITQPXkwTrslfG5L/BksmbLUA0M1iybEsmCWPqzSxsRRhJZimBKJkoMi8fr/CPygPTj4zO5pJH7I2/cm9M7SQ==
+"@types/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==
 
 "@types/command-line-args@^5.0.0":
   version "5.0.0"
@@ -1005,9 +1048,9 @@
   integrity sha512-/xUgezxxYePeXhg5S04hUjxG9JZi+rJTs1+4NwpYPfSaS7BeDa6tVJkH6lN9Cb6rl8d24Fi2uX0s0Ngg2JT6gg==
 
 "@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.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" "*"
 
@@ -1017,9 +1060,9 @@
   integrity sha512-P1bffQfhD3O4LW0ioENXUhZ9OIa0Zn+P7M+pWgkCKaT53wVLSq0mrKksCID/FGHpFhRSxRGhgrQmfhRuzwtKdg==
 
 "@types/cookies@*":
-  version "0.7.4"
-  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.4.tgz#26dedf791701abc0e36b5b79a5722f40e455f87b"
-  integrity sha512-oTGtMzZZAVuEjTwCjIh8T8FrC8n/uwy+PG0yTvQcdZ7etoel7C7/3MSd7qrukENTgQtotG7gvBlBojuVs7X5rw==
+  version "0.7.6"
+  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.6.tgz#71212c5391a976d3bae57d4b09fac20fc6bda504"
+  integrity sha512-FK4U5Qyn7/Sc5ih233OuHO0qAkOpEcD/eG6584yEiLKizTFRny86qHLe/rej3HFQrkBuUjF4whFliAdODbVN/w==
   dependencies:
     "@types/connect" "*"
     "@types/express" "*"
@@ -1043,22 +1086,22 @@
   dependencies:
     "@types/node" "*"
 
-"@types/express-serve-static-core@*":
-  version "4.17.8"
-  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.8.tgz#b8f7b714138536742da222839892e203df569d1c"
-  integrity sha512-1SJZ+R3Q/7mLkOD9ewCBDYD2k0WyZQtWYqF/2VvoNN2/uhI49J9CDN4OAm+wGMA0DbArA4ef27xl4+JwMtGggw==
+"@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@*":
-  version "4.17.6"
-  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.6.tgz#6bce49e49570507b86ea1b07b806f04697fac45e"
-  integrity sha512-n/mr9tZI83kd4azlPG5y997C/M4DNABK9yErhFM6hKdym4kkmd9j0vtsJyjFIwfRBxtrxZtAfGZCNRIBMFLK5w==
+  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" "*"
+    "@types/express-serve-static-core" "^4.17.18"
     "@types/qs" "*"
     "@types/serve-static" "*"
 
@@ -1067,6 +1110,11 @@
   resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.1.tgz#d775e93630c2469c2f980fc27e3143240335db3b"
   integrity sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ==
 
+"@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==
+
 "@types/keygrip@*":
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72"
@@ -1111,54 +1159,60 @@
     "@types/koa-send" "*"
 
 "@types/koa@*", "@types/koa@^2.0.48":
-  version "2.11.3"
-  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.11.3.tgz#540ece376581b12beadf9a417dd1731bc31c16ce"
-  integrity sha512-ABxVkrNWa4O/Jp24EYI/hRNqEVRlhB9g09p48neQp4m3xL1TJtdWk2NyNQSMCU45ejeELMQZBYyfstyVvO2H3Q==
+  version "2.13.1"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.1.tgz#e29877a6b5ad3744ab1024f6ec75b8cbf6ec45db"
+  integrity sha512-Qbno7FWom9nNqu0yHZ6A0+RWt4mrYBhw3wpBAQ3+IuzGcLlfeYkzZrnMq5wsxulN2np8M4KKeUpTodsOsSad5Q==
   dependencies:
     "@types/accepts" "*"
     "@types/content-disposition" "*"
     "@types/cookies" "*"
     "@types/http-assert" "*"
+    "@types/http-errors" "*"
     "@types/keygrip" "*"
     "@types/koa-compose" "*"
     "@types/node" "*"
 
 "@types/koa__cors@^3.0.1":
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/@types/koa__cors/-/koa__cors-3.0.1.tgz#a8cf8535f0fe682c9421f1b9379837c585f8b66b"
-  integrity sha512-loqZNXliley8kncc4wrX9KMqLGN6YfiaO3a3VFX+yVkkXJwOrZU4lipdudNjw5mFyC+5hd7h9075hQWcVVpeOg==
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@types/koa__cors/-/koa__cors-3.0.2.tgz#578917ffca964e98f5e9849996ae1eeda7c15704"
+  integrity sha512-gBetQR0DJ9JTG1YQoW33BADHCrDPJGiJUKUUcEPJwW1A2unzpIMhorEpXB6eMaaXTaqHLemcGnq3RmH9XaryRQ==
   dependencies:
     "@types/koa" "*"
 
-"@types/lodash@^4.14.162":
-  version "4.14.162"
-  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.162.tgz#65d78c397e0d883f44afbf1f7ba9867022411470"
-  integrity sha512-alvcho1kRUnnD1Gcl4J+hK0eencvzq9rmzvFPRmP5rPHx9VVsJj6bKLTATPVf9ktgv4ujzh7T+XWKp+jhuODig==
+"@types/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==
 
 "@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==
 
-"@types/mime@*":
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.2.tgz#857a118d8634c84bba7ae14088e4508490cd5da5"
-  integrity sha512-4kPlzbljFcsttWEq6aBW0OZe6BDajAmyvr2xknBG92tejQnvdGtT9+kXSZ580DqpxY9qG2xeQVF9Dq0ymUTo5Q==
+"@types/mime-types@^2.1.0":
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.0.tgz#9ca52cda363f699c69466c2a6ccdaad913ea7a73"
+  integrity sha1-nKUs2jY/aZxpRmwqbM2q2RPqenM=
+
+"@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@^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==
+  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/mocha@^8.0.3":
-  version "8.0.3"
-  resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.0.3.tgz#51b21b6acb6d1b923bbdc7725c38f9f455166402"
-  integrity sha512-vyxR57nv8NfcU0GZu8EUXZLTbCMupIUwy95LJ6lllN+JRPG25CwMHoB1q5xKh8YKhQnHYRAn4yW2yuHbf/5xgg==
+"@types/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==
 
 "@types/node@*":
-  version "14.0.14"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.14.tgz#24a0b5959f16ac141aeb0c5b3cd7a15b7c64cbce"
-  integrity sha512-syUgf67ZQpaJj01/tRTknkMNoBBLWJOBODF0Zm4NrXmiSuxjymFrxnTu1QVYRubhVkRcZLYZG8STTwJRdVm/WQ==
+  version "14.14.37"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.37.tgz#a3dd8da4eb84a996c36e331df98d82abd76b516e"
+  integrity sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw==
 
 "@types/path-is-inside@^1.0.0":
   version "1.0.0"
@@ -1166,9 +1220,9 @@
   integrity sha512-hfnXRGugz+McgX2jxyy5qz9sB21LRzlGn24zlwN2KEgoPtEvjzNRrLtUkOOebPDPZl3Rq7ywKxYvylVcEZDnEw==
 
 "@types/qs@*":
-  version "6.9.3"
-  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.3.tgz#b755a0934564a200d3efdf88546ec93c369abd03"
-  integrity sha512-7s9EQWupR1fTc2pSMtXRQ9w9gLOcrJn+h7HOXw4evxyvVqMi4f+q7d2tnFe3ng3SNHjtK+0EzGMGFUQX4/AQRA==
+  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"
@@ -1183,24 +1237,19 @@
     "@types/node" "*"
 
 "@types/serve-static@*":
-  version "1.13.4"
-  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.4.tgz#6662a93583e5a6cabca1b23592eb91e12fa80e7c"
-  integrity sha512-jTDt0o/YbpNwZbQmE/+2e+lfjJEJJR0I3OFaKQKPWkASkCoW3i6fsUnqudSMcNAfbtmADGu8f4MV4q+GqULmug==
+  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/express-serve-static-core" "*"
-    "@types/mime" "*"
+    "@types/mime" "^1"
+    "@types/node" "*"
 
-"@types/sinon@^9.0.8":
-  version "9.0.8"
-  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-9.0.8.tgz#1ed0038d356784f75b086104ef83bfd4130bb81b"
-  integrity sha512-IVnI820FZFMGI+u1R+2VdRaD/82YIQTdqLYC9DLPszZuynAJDtCvCtCs3bmyL66s7FqRM3+LPX7DhHnVTaagDw==
+"@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==
   dependencies:
-    "@types/sinonjs__fake-timers" "*"
-
-"@types/sinonjs__fake-timers@*":
-  version "6.0.2"
-  resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz#3a84cf5ec3249439015e14049bd3161419bf9eae"
-  integrity sha512-dIPoZ3g5gcx9zZEszaxLSVTvMReD3xxyyDnQUjA6IYDG9Ba2AV0otMPs+77sG9ojB4Qr2N2Vk5RnKeuA0X/0bg==
+    "@sinonjs/fake-timers" "^7.0.4"
 
 "@types/whatwg-url@^6.4.0":
   version "6.4.0"
@@ -1209,25 +1258,25 @@
   dependencies:
     "@types/node" "*"
 
-"@webcomponents/shadycss@^1.9.1":
-  version "1.9.4"
-  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.9.4.tgz#4f9d8ea1526bab084c60b53d4854dc39fdb2bb48"
-  integrity sha512-tgNcVEaKssyeZPbUBjVQf4aryO5Fi7fxRvOxV982ZJuRVDcefmIblBh0SXAbcvAAlQ2zpNEP4SuQUnr8uApIpw==
+"@ungap/promise-all-settled@1.1.2":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44"
+  integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==
 
-"@webcomponents/shadycss@^1.9.4":
-  version "1.10.0"
-  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.10.0.tgz#7a80ec1e8b271fb3f0cc02cd4358b877a303545d"
-  integrity sha512-UMS+dF4DXDrcUmQqK6aLd/3mFyfGktKG/hZR6FtrsQK/INO07G0H8FxElLkuvHj0iePeZGpR7R4lWFTvX7rc9g==
+"@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==
 
-"@webcomponents/webcomponentsjs@^2.4.0":
-  version "2.4.3"
-  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.4.3.tgz#384f4f6d54563ba465fb4df21fe89e78a76fc530"
-  integrity sha512-cV4+sAmshf8ysU2USutrSRYQkJzEYKHsRCGa0CkMElGpG5747VHtkfsW3NdVIBV/m2MDKXTDydT4lkrysH7IFA==
+"@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==
 
 abortcontroller-polyfill@^1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.4.0.tgz#0d5eb58e522a461774af8086414f68e1dda7a6c4"
-  integrity sha512-3ZFfCRfDzx3GFjO6RAkYx81lPGpUS20ISxux9gLxuKnqafNcFQo59+IoZqpO2WvQlyc287B62HDnDdNYRmlvWA==
+  version "1.7.1"
+  resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.1.tgz#27084bac87d78a7224c8ee78135d05df430c2d2f"
+  integrity sha512-yml9NiDEH4M4p0G4AcPkg8AAa4mF3nfYF28VQxaokpO67j9H7gWgmsVWJ/f1Rn+PzsnDYvzJzWIQzCqDKRvWlA==
 
 accepts@^1.3.5, accepts@~1.3.4:
   version "1.3.7"
@@ -1247,20 +1296,20 @@
   resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
   integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=
 
-ajv@^6.5.5:
-  version "6.12.2"
-  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.2.tgz#c629c5eced17baf314437918d2da88c99d5958cd"
-  integrity sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==
+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"
     json-schema-traverse "^0.4.1"
     uri-js "^4.2.2"
 
-ansi-colors@3.2.3:
-  version "3.2.3"
-  resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.3.tgz#57d35b8686e851e2cc04c403f1c00203976a1813"
-  integrity sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw==
+ansi-colors@4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"
+  integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==
 
 ansi-regex@^3.0.0:
   version "3.0.0"
@@ -1272,39 +1321,49 @@
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
   integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==
 
-ansi-styles@^3.2.0, ansi-styles@^3.2.1:
+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@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
   integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
   dependencies:
     color-convert "^1.9.0"
 
+ansi-styles@^4.0.0, ansi-styles@^4.1.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
+  integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
+  dependencies:
+    color-convert "^2.0.1"
+
 any-promise@^1.0.0, any-promise@^1.1.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
   integrity sha1-q8av7tzqUugJzcA3au0845Y10X8=
 
 anymatch@~3.1.1:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142"
-  integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
+  integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
   dependencies:
     normalize-path "^3.0.0"
     picomatch "^2.0.4"
 
-argparse@^1.0.7:
-  version "1.0.10"
-  resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
-  integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==
-  dependencies:
-    sprintf-js "~1.0.2"
+argparse@^2.0.1:
+  version "2.0.1"
+  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:
   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.0, array-back@^4.0.1:
+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==
@@ -1359,9 +1418,9 @@
   integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
 
 aws4@^1.8.0:
-  version "1.10.0"
-  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.10.0.tgz#a17b3a8ea811060e74d47d306122400ad4497ae2"
-  integrity sha512-3YDiu347mtVtjpyV3u5kVqQLP242c06zwDOgpeRnybmXlYYsLbtTrUBUm8i8srONt+FWobl5aibnU1030PeeuA==
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
+  integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
 
 babel-plugin-dynamic-import-node@^2.3.3:
   version "2.3.3"
@@ -1380,15 +1439,39 @@
     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==
+  dependencies:
+    "@babel/compat-data" "^7.13.0"
+    "@babel/helper-define-polyfill-provider" "^0.1.5"
+    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==
+  dependencies:
+    "@babel/helper-define-polyfill-provider" "^0.1.5"
+    core-js-compat "^3.8.1"
+
+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==
+  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=
 
 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"
@@ -1415,9 +1498,9 @@
     callsite "1.0.0"
 
 binary-extensions@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c"
-  integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==
+  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"
@@ -1474,15 +1557,16 @@
     semver "^7.3.2"
     useragent "^2.3.0"
 
-browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.8.5, browserslist@^4.9.1:
-  version "4.12.2"
-  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.12.2.tgz#76653d7e4c57caa8a1a28513e2f4e197dc11a711"
-  integrity sha512-MfZaeYqR8StRZdstAK9hCKDd2StvePCYp5rHzQCPicUjfFliDgmuaBNPHYUTpAywBN8+Wc/d7NYVFkO0aqaBUw==
+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==
   dependencies:
-    caniuse-lite "^1.0.30001088"
-    electron-to-chromium "^1.3.483"
-    escalade "^3.0.1"
-    node-releases "^1.1.58"
+    caniuse-lite "^1.0.30001181"
+    colorette "^1.2.1"
+    electron-to-chromium "^1.3.649"
+    escalade "^3.1.1"
+    node-releases "^1.1.70"
 
 buffer-alloc-unsafe@^1.1.0:
   version "1.1.0"
@@ -1508,9 +1592,9 @@
   integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
 
 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==
 
 bytes@3.1.0, bytes@^3.0.0:
   version "3.1.0"
@@ -1525,24 +1609,37 @@
     mime-types "^2.1.18"
     ylru "^1.2.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"
+
 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.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73"
-  integrity sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=
+camel-case@^4.1.1:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a"
+  integrity sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==
   dependencies:
-    no-case "^2.2.0"
-    upper-case "^1.1.1"
+    pascal-case "^3.1.2"
+    tslib "^2.0.3"
 
-camelcase@^5.0.0, camelcase@^5.3.1:
+camelcase@^5.3.1:
   version "5.3.1"
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
   integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
 
+camelcase@^6.0.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809"
+  integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==
+
 caniuse-api@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0"
@@ -1553,26 +1650,26 @@
     lodash.memoize "^4.1.2"
     lodash.uniq "^4.5.0"
 
-caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001033, caniuse-lite@^1.0.30001088:
-  version "1.0.30001093"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001093.tgz#833e80f64b1a0455cbceed2a4a3baf19e4abd312"
-  integrity sha512-0+ODNoOjtWD5eS9aaIpf4K0gQqZfILNY4WSNuYzeT1sXni+lMrrVjc0odEobJt6wrODofDZUX8XYi/5y7+xl8g==
+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==
 
 caseless@~0.12.0:
   version "0.12.0"
   resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
   integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
 
-chai@^4.2.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/chai/-/chai-4.2.0.tgz#760aa72cf20e3795e84b12877ce0e83737aa29e5"
-  integrity sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw==
+chai@^4.3.4:
+  version "4.3.4"
+  resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.4.tgz#b55e655b31e1eac7099be4c08c21964fce2e6c49"
+  integrity sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==
   dependencies:
     assertion-error "^1.1.0"
     check-error "^1.0.2"
     deep-eql "^3.0.1"
     get-func-name "^2.0.0"
-    pathval "^1.1.0"
+    pathval "^1.1.1"
     type-detect "^4.0.5"
 
 chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.2:
@@ -1584,15 +1681,23 @@
     escape-string-regexp "^1.0.5"
     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==
+  dependencies:
+    ansi-styles "^4.1.0"
+    supports-color "^7.1.0"
+
 check-error@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
   integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=
 
-chokidar@3.3.0:
-  version "3.3.0"
-  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.0.tgz#12c0714668c55800f659e262d4962a97faf554a6"
-  integrity sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A==
+chokidar@3.5.1, chokidar@^3.0.0, chokidar@^3.4.3:
+  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==
   dependencies:
     anymatch "~3.1.1"
     braces "~3.0.2"
@@ -1600,40 +1705,25 @@
     is-binary-path "~2.1.0"
     is-glob "~4.0.1"
     normalize-path "~3.0.0"
-    readdirp "~3.2.0"
+    readdirp "~3.5.0"
   optionalDependencies:
-    fsevents "~2.1.1"
+    fsevents "~2.3.1"
 
-chokidar@^3.0.0:
-  version "3.3.1"
-  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.1.tgz#c84e5b3d18d9a4d77558fef466b1bf16bbeb3450"
-  integrity sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg==
-  dependencies:
-    anymatch "~3.1.1"
-    braces "~3.0.2"
-    glob-parent "~5.1.0"
-    is-binary-path "~2.1.0"
-    is-glob "~4.0.1"
-    normalize-path "~3.0.0"
-    readdirp "~3.3.0"
-  optionalDependencies:
-    fsevents "~2.1.2"
-
-clean-css@^4.2.1:
+clean-css@^4.2.3:
   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"
 
-cliui@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5"
-  integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==
+cliui@^7.0.2:
+  version "7.0.4"
+  resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f"
+  integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==
   dependencies:
-    string-width "^3.1.0"
-    strip-ansi "^5.2.0"
-    wrap-ansi "^5.1.0"
+    string-width "^4.2.0"
+    strip-ansi "^6.0.0"
+    wrap-ansi "^7.0.0"
 
 clone@^2.1.2:
   version "2.1.2"
@@ -1652,11 +1742,28 @@
   dependencies:
     color-name "1.1.3"
 
+color-convert@^2.0.1:
+  version "2.0.1"
+  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.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
   integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
 
+color-name@~1.1.4:
+  version "1.1.4"
+  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==
+
 colors@^1.1.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
@@ -1680,20 +1787,25 @@
     typical "^4.0.0"
 
 command-line-usage@^6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.0.tgz#f28376a3da3361ff3d36cfd31c3c22c9a64c7cb6"
-  integrity sha512-Ew1clU4pkUeo6AFVDFxCbnN7GIZfXl48HIOQeFQnkO3oOqvpI7wdqtLRwv9iOCZ/7A+z4csVZeiDdEcj8g6Wiw==
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.1.tgz#c908e28686108917758a49f45efb4f02f76bc03f"
+  integrity sha512-F59pEuAR9o1SF/bD0dQBDluhpT4jJQNWUHEuVBqpDmCUo6gPjCi+m9fCWnWZVR/oG6cMTUms4h+3NPl74wGXvA==
   dependencies:
-    array-back "^4.0.0"
+    array-back "^4.0.1"
     chalk "^2.4.2"
-    table-layout "^1.0.0"
+    table-layout "^1.0.1"
     typical "^5.2.0"
 
-commander@^2.19.0, commander@^2.20.0:
+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@^4.1.1:
+  version "4.1.1"
+  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"
@@ -1763,17 +1875,17 @@
     depd "~2.0.0"
     keygrip "~1.1.0"
 
-core-js-bundle@^3.6.0:
-  version "3.6.5"
-  resolved "https://registry.yarnpkg.com/core-js-bundle/-/core-js-bundle-3.6.5.tgz#3a425ad66ad19aeefea89acfd48cff674ff58590"
-  integrity sha512-awf49McIBT3sDXceSex69w/i7PMXQwxI4ZqknCtaYbW4Q0u0HUZiaQLlPD6pU2nFBofIowgWIS1ANgHjqnQu4Q==
+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==
 
-core-js-compat@^3.6.2:
-  version "3.6.5"
-  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.6.5.tgz#2a51d9a4e25dfd6e690251aa81f99e3c05481f1c"
-  integrity sha512-7ItTKOhOZbznhXAQ2g/slGg1PJV5zDO/WdkTwi7UEOJmkvsE32PWvx6mKtDjiMpjnR2CNf6BAD6sSxIlv7ptng==
+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==
   dependencies:
-    browserslist "^4.8.5"
+    browserslist "^4.16.3"
     semver "7.0.0"
 
 core-util-is@1.0.2:
@@ -1799,9 +1911,9 @@
   integrity sha512-bYQuGLeFxhkxNOF3rcMtiZxvCBAquGzZm6oWA1oZ0g2THUzivaRhv8uOhdr19LmoobSOLoIAxeUK2RdbM8IFTA==
 
 debounce@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.0.tgz#44a540abc0ea9943018dc0eaa95cce87f65cd131"
-  integrity sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg==
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
+  integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==
 
 debug@2.6.9:
   version "2.6.9"
@@ -1810,17 +1922,17 @@
   dependencies:
     ms "2.0.0"
 
-debug@3.2.6, debug@^3.0.0, debug@^3.1.0, debug@^3.1.1, debug@^3.2.6:
-  version "3.2.6"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
-  integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
+debug@4.3.1, debug@^4.1.0, debug@^4.1.1:
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee"
+  integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==
   dependencies:
-    ms "^2.1.1"
+    ms "2.1.2"
 
-debug@^4.1.0, debug@^4.1.1:
-  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, debug@^3.1.1, debug@^3.2.6:
+  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"
 
@@ -1831,10 +1943,10 @@
   dependencies:
     ms "2.0.0"
 
-decamelize@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
-  integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
+decamelize@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837"
+  integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==
 
 deep-eql@^3.0.1:
   version "3.0.1"
@@ -1858,7 +1970,7 @@
   resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
   integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
 
-define-properties@^1.1.2, define-properties@^1.1.3:
+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==
@@ -1875,16 +1987,16 @@
   resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
   integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
 
-depd@^1.1.2, depd@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
-  integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
-
-depd@~2.0.0:
+depd@^2.0.0, depd@~2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
   integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
 
+depd@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
+  integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
+
 destroy@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
@@ -1895,10 +2007,10 @@
   resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c"
   integrity sha1-gGZJMmzqp8qjMG112YXqJ0i6kTw=
 
-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@5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b"
+  integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==
 
 diff@^4.0.2:
   version "4.0.2"
@@ -1915,6 +2027,14 @@
     extend "^3.0.0"
     void-elements "^2.0.0"
 
+dot-case@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751"
+  integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==
+  dependencies:
+    no-case "^3.0.4"
+    tslib "^2.0.3"
+
 dynamic-import-polyfill@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/dynamic-import-polyfill/-/dynamic-import-polyfill-0.1.1.tgz#e1f9eb1876ee242bd56572f8ed4df768e143083f"
@@ -1933,15 +2053,15 @@
   resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
   integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
 
-electron-to-chromium@^1.3.483:
-  version "1.3.486"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.486.tgz#90856e6c9f488079225cf5a0b4d4af6c241e0965"
-  integrity sha512-fmnACh6Jiuagm9tAfEZNe6QrwvOYAC5y0BwzoEOGCsbqriKOCaafXf3lsIvL55xa75Jmg4oboI7f5tMuoXrjNg==
+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==
 
-emoji-regex@^7.0.1:
-  version "7.0.3"
-  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.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
+  integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
 
 encodeurl@^1.0.2, encodeurl@~1.0.2:
   version "1.0.2"
@@ -2007,41 +2127,24 @@
   dependencies:
     is-arrayish "^0.2.1"
 
-es-abstract@^1.17.0-next.1, es-abstract@^1.17.5:
-  version "1.17.6"
-  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a"
-  integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==
+es-dev-server@^1.57.0:
+  version "1.60.2"
+  resolved "https://registry.yarnpkg.com/es-dev-server/-/es-dev-server-1.60.2.tgz#cca56fe452d46c3ec531c19745e0aa9d7b71e1b3"
+  integrity sha512-Lp9kZzawJ35HDKiqLNb/YbD2VufF+3tdxHgbP/kfdLI5JLgDJV4SuKTWWny3ZuBUAlZKGre7a0iXUByGQqfdPA==
   dependencies:
-    es-to-primitive "^1.2.1"
-    function-bind "^1.1.1"
-    has "^1.0.3"
-    has-symbols "^1.0.1"
-    is-callable "^1.2.0"
-    is-regex "^1.1.0"
-    object-inspect "^1.7.0"
-    object-keys "^1.1.1"
-    object.assign "^4.1.0"
-    string.prototype.trimend "^1.0.1"
-    string.prototype.trimstart "^1.0.1"
-
-es-dev-server@^1.56.0:
-  version "1.56.0"
-  resolved "https://registry.yarnpkg.com/es-dev-server/-/es-dev-server-1.56.0.tgz#8703af87595f02fe9a1c92a07e64b7c7cc915a87"
-  integrity sha512-SL4CXdiku0hiB8zpsBLtEd7b8etIZE6IV0tIi02m0CcpTYV0rDMEvCBUYsQIN5hggJDDTBURgQjOWcT5kQv2eA==
-  dependencies:
-    "@babel/core" "^7.9.0"
-    "@babel/plugin-proposal-dynamic-import" "^7.8.3"
-    "@babel/plugin-proposal-nullish-coalescing-operator" "^7.8.3"
-    "@babel/plugin-proposal-optional-chaining" "^7.9.0"
+    "@babel/core" "^7.11.1"
+    "@babel/plugin-proposal-dynamic-import" "^7.10.4"
+    "@babel/plugin-proposal-nullish-coalescing-operator" "^7.10.4"
+    "@babel/plugin-proposal-optional-chaining" "^7.11.0"
     "@babel/plugin-syntax-class-properties" "^7.8.3"
-    "@babel/plugin-syntax-import-meta" "^7.8.3"
+    "@babel/plugin-syntax-import-meta" "^7.10.4"
     "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
     "@babel/plugin-syntax-numeric-separator" "^7.8.3"
     "@babel/plugin-syntax-optional-chaining" "^7.8.3"
     "@babel/plugin-transform-template-literals" "^7.8.3"
     "@babel/preset-env" "^7.9.0"
     "@koa/cors" "^3.1.0"
-    "@open-wc/building-utils" "^2.18.0"
+    "@open-wc/building-utils" "^2.18.3"
     "@rollup/plugin-node-resolve" "^7.1.1"
     "@rollup/pluginutils" "^3.0.0"
     "@types/babel__core" "^7.1.3"
@@ -2057,6 +2160,7 @@
     "@types/koa-static" "^4.0.1"
     "@types/koa__cors" "^3.0.1"
     "@types/lru-cache" "^5.1.0"
+    "@types/mime-types" "^2.1.0"
     "@types/minimatch" "^3.0.3"
     "@types/path-is-inside" "^1.0.0"
     "@types/whatwg-url" "^6.4.0"
@@ -2085,7 +2189,7 @@
     open "^7.0.3"
     parse5 "^5.1.1"
     path-is-inside "^1.0.2"
-    polyfills-loader "^1.6.1"
+    polyfills-loader "^1.7.4"
     portfinder "^1.0.21"
     rollup "^2.7.2"
     strip-ansi "^5.2.0"
@@ -2095,44 +2199,35 @@
     whatwg-url "^7.0.0"
 
 es-module-lexer@^0.3.13:
-  version "0.3.24"
-  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.3.24.tgz#e6b2900758e9e210d23aec2092efc13ca235adea"
-  integrity sha512-jm/i7KdJtaMDle921xIsA/MQQOGuZ6goYxhlV+k+gQNI7FtP4N6jknrmJvj++3ODpiyFGwQ4PIstJfHJQJNc+g==
+  version "0.3.26"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.3.26.tgz#7b507044e97d5b03b01d4392c74ffeb9c177a83b"
+  integrity sha512-Va0Q/xqtrss45hWzP8CZJwzGSZJjDM5/MJRE3IXXnUCcVLElR9BRaE9F62BopysASyc4nM3uwhSW7FFB9nlWAA==
 
-es-module-shims@^0.4.6:
+es-module-shims@^0.4.6, es-module-shims@^0.4.7:
   version "0.4.7"
   resolved "https://registry.yarnpkg.com/es-module-shims/-/es-module-shims-0.4.7.tgz#1419b65bbd38dfe91ab8ea5d7b4b454561e44641"
   integrity sha512-0LTiSQoPWwdcaTVIQXhGlaDwTneD0g9/tnH1PNs3zHFFH+xoCeJclDM3rQeqF9nurXPfMKm3l9+kfPRa5VpbKg==
 
-es-to-primitive@^1.2.1:
-  version "1.2.1"
-  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"
-
-escalade@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.0.1.tgz#52568a77443f6927cd0ab9c73129137533c965ed"
-  integrity sha512-DR6NO3h9niOT+MZs7bjxlj2a1k+POu5RN8CLTPX2+i78bRi9eLe7+0zXgUHMnGXWybYcL61E9hGhPKqedy8tQA==
+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"
   resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
   integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
 
-escape-string-regexp@1.0.5, escape-string-regexp@^1.0.5:
+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==
+
+escape-string-regexp@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
   integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
 
-esprima@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
-  integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
-
 estree-walker@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700"
@@ -2149,9 +2244,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==
 
 extend@^3.0.0, extend@~3.0.2:
   version "3.0.2"
@@ -2205,19 +2300,25 @@
   dependencies:
     array-back "^3.0.1"
 
-find-up@3.0.0, find-up@^3.0.0:
+find-up@5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
+  integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
+  dependencies:
+    locate-path "^6.0.0"
+    path-exists "^4.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"
 
-flat@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/flat/-/flat-4.1.0.tgz#090bec8b05e39cba309747f1d588f04dbaf98db2"
-  integrity sha512-Px/TiLIznH7gEDlPXcUD4KnBusa6kR6ayRUVcnEAbreRIuhkqow/mun59BuRXwoYk7ZQOLW1ZM05ilIvK38hFw==
-  dependencies:
-    is-buffer "~2.0.3"
+flat@^5.0.2:
+  version "5.0.2"
+  resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241"
+  integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==
 
 flatted@^2.0.0:
   version "2.0.2"
@@ -2225,11 +2326,9 @@
   integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==
 
 follow-redirects@^1.0.0:
-  version "1.9.0"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.9.0.tgz#8d5bcdc65b7108fe1508649c79c12d732dcedb4f"
-  integrity sha512-CRcPzsSIbXyVDl0QI01muNDu69S8trU4jArW9LpOt2WtC6LyUJetcIrmfHsRBx7/Jb6GHJUiuqyYxPooFfNt6A==
-  dependencies:
-    debug "^3.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==
 
 forever-agent@~0.6.1:
   version "0.6.1"
@@ -2264,27 +2363,22 @@
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
   integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
 
-fsevents@~2.1.1:
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
-  integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==
-
-fsevents@~2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805"
-  integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA==
+fsevents@~2.3.1:
+  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-caller-file@^2.0.1:
+get-caller-file@^2.0.5:
   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==
@@ -2294,10 +2388,19 @@
   resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
   integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=
 
+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-stream@^5.1.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.1.0.tgz#01203cdc92597f9b909067c3e656cc1f4d3c4dc9"
-  integrity sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==
+  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"
 
@@ -2309,25 +2412,13 @@
     assert-plus "^1.0.0"
 
 glob-parent@~5.1.0:
-  version "5.1.1"
-  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229"
-  integrity sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==
+  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.3:
-  version "7.1.3"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1"
-  integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==
-  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"
-
-glob@^7.1.1, glob@^7.1.3:
+glob@7.1.6, glob@^7.1.1, glob@^7.1.3:
   version "7.1.6"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
   integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
@@ -2345,9 +2436,9 @@
   integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
 
 graceful-fs@^4.1.2, graceful-fs@^4.1.6:
-  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.6"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee"
+  integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==
 
 growl@1.10.5:
   version "1.10.5"
@@ -2360,11 +2451,11 @@
   integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
 
 har-validator@~5.1.3:
-  version "5.1.3"
-  resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080"
-  integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==
+  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-binary2@~1.0.2:
@@ -2389,10 +2480,10 @@
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
   integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
 
-has-symbols@^1.0.0, has-symbols@^1.0.1:
-  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@^1.0.3:
   version "1.0.3"
@@ -2411,18 +2502,18 @@
   resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
   integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==
 
-html-minifier@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-4.0.0.tgz#cca9aad8bce1175e02e17a8c33e46d8988889f56"
-  integrity sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==
+html-minifier-terser@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz#922e96f1f3bb60832c2634b79884096389b1f054"
+  integrity sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==
   dependencies:
-    camel-case "^3.0.0"
-    clean-css "^4.2.1"
-    commander "^2.19.0"
+    camel-case "^4.1.1"
+    clean-css "^4.2.3"
+    commander "^4.1.1"
     he "^1.2.0"
-    param-case "^2.1.1"
+    param-case "^3.0.3"
     relateurl "^0.2.7"
-    uglify-js "^3.5.1"
+    terser "^4.6.3"
 
 http-assert@^1.3.0:
   version "1.4.1"
@@ -2443,7 +2534,7 @@
     statuses ">= 1.5.0 < 2"
     toidentifier "1.0.0"
 
-http-errors@^1.6.3:
+http-errors@^1.6.3, http-errors@^1.7.3:
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.0.tgz#75d1bbe497e1044f51e4ee9e704a62f28d336507"
   integrity sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==
@@ -2476,9 +2567,9 @@
     toidentifier "1.0.0"
 
 http-proxy@^1.13.0:
-  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"
@@ -2528,13 +2619,6 @@
   resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.7.0.tgz#ee16bee978db53516ead2f0a8154b09b400bbdc9"
   integrity sha512-Id0Fij0HsB/vKWGeBe9PxeY45ttRiBmhFyyt/geBdDHBYNctMRTE3dC1U3ujzz3lap+hVXlEcVaB56kZP/eEUg==
 
-invariant@^2.2.2, invariant@^2.2.4:
-  version "2.2.4"
-  resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
-  integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
-  dependencies:
-    loose-envify "^1.0.0"
-
 is-arrayish@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
@@ -2547,25 +2631,17 @@
   dependencies:
     binary-extensions "^2.0.0"
 
-is-buffer@~2.0.3:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.4.tgz#3e572f23c8411a5cfd9557c849e3665e0b290623"
-  integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==
-
-is-callable@^1.1.4, is-callable@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.0.tgz#83336560b54a38e35e3a2df7afd0454d691468bb"
-  integrity sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==
-
-is-date-object@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e"
-  integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==
+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==
+  dependencies:
+    has "^1.0.3"
 
 is-docker@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.0.0.tgz#2cb0df0e75e2d064fe1864c37cdeacb7b2dcf25b"
-  integrity sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ==
+  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==
 
 is-extglob@^2.1.1:
   version "2.1.1"
@@ -2577,10 +2653,15 @@
   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-generator-function@^1.0.7:
-  version "1.0.7"
-  resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.7.tgz#d2132e529bb0000a7f80794d4bdf5cd5e5813522"
-  integrity sha512-YZc5EwyO4f2kWCax7oegfuSr9mFz1ZvieNYBEjmukLxgXfBUbxAWGVF7GZf0zidYtoBl3WvC07YK0wT76a+Rtw==
+  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==
 
 is-glob@^4.0.1, is-glob@~4.0.1:
   version "4.0.1"
@@ -2599,25 +2680,16 @@
   resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
   integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
 
-is-regex@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.0.tgz#ece38e389e490df0dc21caea2bd596f987f767ff"
-  integrity sha512-iI97M8KTWID2la5uYXlkbSDQIg4F6o1sYboZKKTDpnDQMLtUL86zxhgDet3Q2SriaYsyGqZ6Mn2SjbRKeLHdqw==
-  dependencies:
-    has-symbols "^1.0.1"
+is-plain-obj@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
+  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==
 
-is-symbol@^1.0.2:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937"
-  integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==
-  dependencies:
-    has-symbols "^1.0.1"
-
 is-typedarray@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
@@ -2680,18 +2752,17 @@
     istanbul-lib-coverage "^2.0.5"
     semver "^6.0.0"
 
-"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.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
   integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
 
-js-yaml@3.13.1:
-  version "3.13.1"
-  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
-  integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==
+js-yaml@4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.0.0.tgz#f426bc0ff4b4051926cd588c71113183409a121f"
+  integrity sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q==
   dependencies:
-    argparse "^1.0.7"
-    esprima "^4.0.0"
+    argparse "^2.0.1"
 
 jsbn@~0.1.0:
   version "0.1.1"
@@ -2729,9 +2800,9 @@
   integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
 
 json5@^2.1.2:
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43"
-  integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==
+  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.5"
 
@@ -2753,9 +2824,9 @@
     verror "1.10.0"
 
 just-extend@^4.0.2:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.1.0.tgz#7278a4027d889601640ee0ce0e5a00b992467da4"
-  integrity sha512-ApcjaOdVTJ7y4r08xI5wIqpvwS48Q0PBG4DJROcEkH1f8MdAiNFyFxz3xoL0LWAVwjrwPYZdVHHxhRHcx/uGLA==
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744"
+  integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==
 
 karma-chrome-launcher@^3.1.0:
   version "3.1.0"
@@ -2863,13 +2934,12 @@
   integrity sha1-JzwH7c3Ljfaiwat9We52SRRR7BQ=
 
 koa-send@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/koa-send/-/koa-send-5.0.0.tgz#5e8441e07ef55737734d7ced25b842e50646e7eb"
-  integrity sha512-90ZotV7t0p3uN9sRwW2D484rAaKIsD8tAVtypw/aBU+ryfV+fR2xrcAwhI8Wl6WRkojLUs/cB9SBSCuIb+IanQ==
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/koa-send/-/koa-send-5.0.1.tgz#39dceebfafb395d0d60beaffba3a70b4f543fe79"
+  integrity sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==
   dependencies:
-    debug "^3.1.0"
-    http-errors "^1.6.3"
-    mz "^2.7.0"
+    debug "^4.1.1"
+    http-errors "^1.7.3"
     resolve-path "^1.4.0"
 
 koa-static@^5.0.0:
@@ -2881,9 +2951,9 @@
     koa-send "^5.0.0"
 
 koa@^2.7.0:
-  version "2.13.0"
-  resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.0.tgz#25217e05efd3358a7e5ddec00f0a380c9b71b501"
-  integrity sha512-i/XJVOfPw7npbMv67+bOeXr3gPqOAw6uh5wFyNs3QvJ47tUx3M3V9rIE0//WytY42MKz4l/MXKyGkQ2LQTfLUQ==
+  version "2.13.1"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.1.tgz#6275172875b27bcfe1d454356a5b6b9f5a9b1051"
+  integrity sha512-Lb2Dloc72auj5vK4X4qqL7B5jyDPQaZucc9sR/71byg7ryoD1NCaCm63CShk9ID9quQvDEi1bGR/iGjCG7As3w==
   dependencies:
     accepts "^1.3.5"
     cache-content-type "^1.0.0"
@@ -2892,7 +2962,7 @@
     cookies "~0.8.0"
     debug "~3.1.0"
     delegates "^1.0.0"
-    depd "^1.1.2"
+    depd "^2.0.0"
     destroy "^1.0.4"
     encodeurl "^1.0.2"
     escape-html "^1.0.3"
@@ -2909,18 +2979,6 @@
     type-is "^1.6.16"
     vary "^1.1.2"
 
-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==
-
-levenary@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/levenary/-/levenary-1.1.1.tgz#842a9ee98d2075aa7faeedbe32679e9205f46f77"
-  integrity sha512-mkAdOIt79FD6irqjYSs4rdbnlT5vRonMEvBVPVb3XmevfS8kgRXwfes0dhPdEtzTWD/1eNE/Bm/G1iRt6DcnQQ==
-  dependencies:
-    leven "^3.1.0"
-
 load-json-file@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b"
@@ -2939,11 +2997,23 @@
     p-locate "^3.0.0"
     path-exists "^3.0.0"
 
+locate-path@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
+  integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
+  dependencies:
+    p-locate "^5.0.0"
+
 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.debounce@^4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
+  integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
+
 lodash.get@^4.4.2:
   version "4.4.2"
   resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
@@ -2964,17 +3034,17 @@
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
   integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
 
-lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15:
-  version "4.17.15"
-  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
-  integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
+lodash@^4.17.14, lodash@^4.17.19, lodash@^4.17.21:
+  version "4.17.21"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
+  integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
 
-log-symbols@3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4"
-  integrity sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==
+log-symbols@4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.0.0.tgz#69b3cc46d20f448eccdb75ea1fa733d9e821c920"
+  integrity sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA==
   dependencies:
-    chalk "^2.4.2"
+    chalk "^4.0.0"
 
 log-symbols@^2.1.0:
   version "2.2.0"
@@ -2994,17 +3064,12 @@
     rfdc "^1.1.4"
     streamroller "^1.0.6"
 
-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==
+lower-case@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28"
+  integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==
   dependencies:
-    js-tokens "^3.0.0 || ^4.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=
+    tslib "^2.0.3"
 
 lru-cache@4.1.x:
   version "4.1.5"
@@ -3021,39 +3086,34 @@
   dependencies:
     yallist "^3.0.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"
+
 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=
 
-mime-db@1.43.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.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.44.0, "mime-db@>= 1.43.0 < 2":
-  version "1.44.0"
-  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92"
-  integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==
-
-mime-types@^2.1.12, mime-types@^2.1.18, mime-types@^2.1.27, mime-types@~2.1.19:
-  version "2.1.27"
-  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f"
-  integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==
+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==
   dependencies:
-    mime-db "1.44.0"
-
-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==
-  dependencies:
-    mime-db "1.43.0"
+    mime-db "1.47.0"
 
 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==
 
 minimatch@3.0.4, minimatch@^3.0.2, minimatch@^3.0.4:
   version "3.0.4"
@@ -3072,59 +3132,60 @@
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf"
   integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=
 
-mkdirp@0.5.5, mkdirp@^0.5.1:
+mkdirp@^0.5.5:
   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"
 
-mocha@7.2.0:
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/mocha/-/mocha-7.2.0.tgz#01cc227b00d875ab1eed03a75106689cfed5a604"
-  integrity sha512-O9CIypScywTVpNaRrCAgoUnJgozpIofjKUYmJhiCIJMiuYnLI6otcb1/kpW9/n/tJODHGZ7i8aLQoDVsMtOKQQ==
+mocha@8.3.2:
+  version "8.3.2"
+  resolved "https://registry.yarnpkg.com/mocha/-/mocha-8.3.2.tgz#53406f195fa86fbdebe71f8b1c6fb23221d69fcc"
+  integrity sha512-UdmISwr/5w+uXLPKspgoV7/RXZwKRTiTjJ2/AC5ZiEztIoOYdfKb19+9jNmEInzx5pBsCyJQzarAxqIGBNYJhg==
   dependencies:
-    ansi-colors "3.2.3"
+    "@ungap/promise-all-settled" "1.1.2"
+    ansi-colors "4.1.1"
     browser-stdout "1.3.1"
-    chokidar "3.3.0"
-    debug "3.2.6"
-    diff "3.5.0"
-    escape-string-regexp "1.0.5"
-    find-up "3.0.0"
-    glob "7.1.3"
+    chokidar "3.5.1"
+    debug "4.3.1"
+    diff "5.0.0"
+    escape-string-regexp "4.0.0"
+    find-up "5.0.0"
+    glob "7.1.6"
     growl "1.10.5"
     he "1.2.0"
-    js-yaml "3.13.1"
-    log-symbols "3.0.0"
+    js-yaml "4.0.0"
+    log-symbols "4.0.0"
     minimatch "3.0.4"
-    mkdirp "0.5.5"
-    ms "2.1.1"
-    node-environment-flags "1.0.6"
-    object.assign "4.1.0"
-    strip-json-comments "2.0.1"
-    supports-color "6.0.0"
-    which "1.3.1"
+    ms "2.1.3"
+    nanoid "3.1.20"
+    serialize-javascript "5.0.1"
+    strip-json-comments "3.1.1"
+    supports-color "8.1.1"
+    which "2.0.2"
     wide-align "1.1.3"
-    yargs "13.3.2"
-    yargs-parser "13.1.2"
-    yargs-unparser "1.6.0"
+    workerpool "6.1.0"
+    yargs "16.2.0"
+    yargs-parser "20.2.4"
+    yargs-unparser "2.0.0"
 
 ms@2.0.0:
   version "2.0.0"
   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.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==
 
-mz@^2.1.0, mz@^2.7.0:
+ms@2.1.3, 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==
+
+mz@^2.1.0:
   version "2.7.0"
   resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
   integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
@@ -3133,15 +3194,20 @@
     object-assign "^4.0.1"
     thenify-all "^1.0.0"
 
+nanoid@3.1.20:
+  version "3.1.20"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788"
+  integrity sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw==
+
 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==
 
-nise@^4.0.1:
-  version "4.0.4"
-  resolved "https://registry.yarnpkg.com/nise/-/nise-4.0.4.tgz#d73dea3e5731e6561992b8f570be9e363c4512dd"
-  integrity sha512-bTTRUNlemx6deJa+ZyoCUTRvH3liK5+N6VQZ4NIw90AgDXY6iPnsqplNFf6STcj+ePk0H/xqxnP75Lr0J0Fq3A==
+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==
   dependencies:
     "@sinonjs/commons" "^1.7.0"
     "@sinonjs/fake-timers" "^6.0.0"
@@ -3149,30 +3215,23 @@
     just-extend "^4.0.2"
     path-to-regexp "^1.7.0"
 
-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==
+no-case@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d"
+  integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==
   dependencies:
-    lower-case "^1.1.1"
-
-node-environment-flags@1.0.6:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.6.tgz#a30ac13621f6f7d674260a54dede048c3982c088"
-  integrity sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw==
-  dependencies:
-    object.getownpropertydescriptors "^2.0.3"
-    semver "^5.7.0"
+    lower-case "^2.0.2"
+    tslib "^2.0.3"
 
 node-fetch@^2.6.0:
-  version "2.6.0"
-  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
-  integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
+  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.58:
-  version "1.1.58"
-  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.58.tgz#8ee20eef30fa60e52755fcc0942def5a734fe935"
-  integrity sha512-NxBudgVKiRh/2aPWMgPR7bPTX0VPmGx5QBwCtdHitnqFE5/O8DeBXuIMH1nwNnw/aMo6AjOrpsHzfY3UbUJ7yg==
+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==
 
 normalize-package-data@^2.3.2:
   version "2.5.0"
@@ -3204,33 +3263,20 @@
   resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291"
   integrity sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=
 
-object-inspect@^1.7.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0"
-  integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==
-
-object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1:
+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==
 
-object.assign@4.1.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==
+object.assign@^4.1.0:
+  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"
-
-object.getownpropertydescriptors@^2.0.3:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649"
-  integrity sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg==
-  dependencies:
+    call-bind "^1.0.0"
     define-properties "^1.1.3"
-    es-abstract "^1.17.0-next.1"
+    has-symbols "^1.0.1"
+    object-keys "^1.1.1"
 
 on-finished@^2.3.0, on-finished@~2.3.0:
   version "2.3.0"
@@ -3252,9 +3298,9 @@
   integrity sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q=
 
 open@^7.0.3:
-  version "7.0.4"
-  resolved "https://registry.yarnpkg.com/open/-/open-7.0.4.tgz#c28a9d315e5c98340bf979fdcb2e58664aa10d83"
-  integrity sha512-brSA+/yq+b08Hsr4c8fsEW2CRzk1BmfN3SAK/5VCHQ9bdoZJ4qa/+AfR0xHjlbbZUyPkUHs1b8x1RqdyZdkVqQ==
+  version "7.4.2"
+  resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321"
+  integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==
   dependencies:
     is-docker "^2.0.0"
     is-wsl "^2.1.1"
@@ -3273,12 +3319,19 @@
   integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
 
 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"
 
+p-limit@^3.0.2:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
+  integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
+  dependencies:
+    yocto-queue "^0.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"
@@ -3286,17 +3339,25 @@
   dependencies:
     p-limit "^2.0.0"
 
+p-locate@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
+  integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
+  dependencies:
+    p-limit "^3.0.2"
+
 p-try@^2.0.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
   integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
 
-param-case@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/param-case/-/param-case-2.1.1.tgz#df94fd8cf6531ecf75e6bef9a0858fbc72be2247"
-  integrity sha1-35T9jPZTHs915r75oIWPvHK+Ikc=
+param-case@^3.0.3:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5"
+  integrity sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==
   dependencies:
-    no-case "^2.2.0"
+    dot-case "^3.0.4"
+    tslib "^2.0.3"
 
 parse-json@^4.0.0:
   version "4.0.0"
@@ -3330,11 +3391,24 @@
   resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
   integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
 
+pascal-case@^3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.2.tgz#b48e0ef2b98e205e7c1dae747d0b1508237660eb"
+  integrity sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==
+  dependencies:
+    no-case "^3.0.4"
+    tslib "^2.0.3"
+
 path-exists@^3.0.0:
   version "3.0.0"
   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.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
+  integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
+
 path-is-absolute@1.0.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"
@@ -3364,17 +3438,17 @@
   dependencies:
     pify "^3.0.0"
 
-pathval@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0"
-  integrity sha1-uULm1L3mUwBe9rcTYd74cn0GReA=
+pathval@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d"
+  integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==
 
 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.4, picomatch@^2.0.7, picomatch@^2.2.2:
+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==
@@ -3384,20 +3458,19 @@
   resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
   integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=
 
-polyfills-loader@^1.6.1:
-  version "1.6.1"
-  resolved "https://registry.yarnpkg.com/polyfills-loader/-/polyfills-loader-1.6.1.tgz#134ab74b9a6160efb4d72066a5150bfb2228fad3"
-  integrity sha512-GK3jZGLy9nApfRYfHrrO4RYkBkpjiXUVWVdp169g4Y8HV+ZazrGQX46tNpbwP0dtrgHgADyJvZYPfdFuooHy5Q==
+polyfills-loader@^1.6.1, polyfills-loader@^1.7.4:
+  version "1.7.6"
+  resolved "https://registry.yarnpkg.com/polyfills-loader/-/polyfills-loader-1.7.6.tgz#5cff98bfc9689cf10e44bdd32f498cfeb4374c51"
+  integrity sha512-AiLIgmGFmzcvsqewyKsqWb7H8CnWNTSQBoM0u+Mauzmp0DsjObXmnZdeqvTn0HNwc1wYHHTOta82WjSjG341eQ==
   dependencies:
-    "@babel/core" "^7.9.0"
-    "@open-wc/building-utils" "^2.18.0"
+    "@babel/core" "^7.11.1"
+    "@open-wc/building-utils" "^2.18.3"
     "@webcomponents/webcomponentsjs" "^2.4.0"
     abortcontroller-polyfill "^1.4.0"
     core-js-bundle "^3.6.0"
     deepmerge "^4.2.2"
     dynamic-import-polyfill "^0.1.1"
     es-module-shims "^0.4.6"
-    html-minifier "^4.0.0"
     intersection-observer "^0.7.0"
     parse5 "^5.1.1"
     regenerator-runtime "^0.13.3"
@@ -3407,13 +3480,13 @@
     whatwg-fetch "^3.0.0"
 
 portfinder@^1.0.21:
-  version "1.0.26"
-  resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.26.tgz#475658d56ca30bed72ac7f1378ed350bd1b64e70"
-  integrity sha512-Xi7mKxJHHMI3rIUrnm/jjUgwhbYMkp/XKEcZX3aG4BrumLpq3nmoQMX+ClYnDZnZ/New7IatC1no5RX0zo1vXQ==
+  version "1.0.28"
+  resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778"
+  integrity sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA==
   dependencies:
     async "^2.6.2"
     debug "^3.1.1"
-    mkdirp "^0.5.1"
+    mkdirp "^0.5.5"
 
 pseudomap@^1.0.2:
   version "1.0.2"
@@ -3453,6 +3526,13 @@
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
   integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
 
+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:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
@@ -3485,19 +3565,12 @@
     normalize-package-data "^2.3.2"
     path-type "^3.0.0"
 
-readdirp@~3.2.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.2.0.tgz#c30c33352b12c96dfb4b895421a49fd5a9593839"
-  integrity sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ==
+readdirp@~3.5.0:
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e"
+  integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==
   dependencies:
-    picomatch "^2.0.4"
-
-readdirp@~3.3.0:
-  version "3.3.0"
-  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.3.0.tgz#984458d13a1e42e2e9f5841b129e162f369aff17"
-  integrity sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ==
-  dependencies:
-    picomatch "^2.0.7"
+    picomatch "^2.2.1"
 
 reduce-flatten@^2.0.0:
   version "2.0.0"
@@ -3512,14 +3585,14 @@
     regenerate "^1.4.0"
 
 regenerate@^1.4.0:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.1.tgz#cad92ad8e6b591773485fbe05a485caf4f457e6f"
-  integrity sha512-j2+C8+NtXQgEKWk49MMP5P/u2GhnahTtVkRIHr5R5lVRlbKvmQ+oS+A5aLKWp2ma5VkT8sh6v+v4hbH0YHR66A==
+  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.13.3, regenerator-runtime@^0.13.4:
-  version "0.13.5"
-  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697"
-  integrity sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==
+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==
 
 regenerator-transform@^0.14.2:
   version "0.14.5"
@@ -3528,10 +3601,10 @@
   dependencies:
     "@babel/runtime" "^7.8.4"
 
-regexpu-core@^4.7.0:
-  version "4.7.0"
-  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.7.0.tgz#fcbf458c50431b0bb7b45d6967b8192d91f3d938"
-  integrity sha512-TQ4KXRnIn6tz6tjnrXEkD/sshygKH/j5KzK86X8MkeHyZ8qst/LZ89j3X4/8HEIfHANTFIP/AbXakeRhWIl5YQ==
+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"
@@ -3546,9 +3619,9 @@
   integrity sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A==
 
 regjsparser@^0.6.4:
-  version "0.6.4"
-  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.4.tgz#a769f8684308401a66e9b529d2436ff4d0666272"
-  integrity sha512-64O87/dPDgfk8/RQqC4gkZoGyyWFIEUTTh80CU6CWuK5vkCGyekIx+oKcEIYtP/RAxSQltCZHCNu/mdd7fqlJw==
+  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"
 
@@ -3611,17 +3684,18 @@
     http-errors "~1.6.2"
     path-is-absolute "1.0.1"
 
-resolve@^1.10.0, resolve@^1.11.1, resolve@^1.14.2, resolve@^1.3.2:
-  version "1.17.0"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444"
-  integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==
+resolve@^1.10.0, resolve@^1.14.2, resolve@^1.19.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"
 
 rfdc@^1.1.4:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.1.4.tgz#ba72cc1367a0ccd9cf81a870b3b58bd3ad07f8c2"
-  integrity sha512-5C9HXdzK8EAqN7JDif30jqsBzavB7wLpaubisuQIGHWf2gUXSpzy6ArX/+Da8RjFpagWsCn+pIgxTMAmKw9Zug==
+  version "1.3.0"
+  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"
@@ -3638,23 +3712,18 @@
     glob "^7.1.3"
 
 rollup@^2.7.2:
-  version "2.18.2"
-  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.18.2.tgz#886ac6e4549e493df106c3e2580c89aeb997be25"
-  integrity sha512-+mzyZhL9ZyLB3eHBISxRNTep9Z2qCuwXzAYkUbFyz7yNKaKH03MFKeiGOS1nv2uvPgDb4ASKv+FiS5mC4h5IFQ==
+  version "2.44.0"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.44.0.tgz#8da324d1c4fd12beef9ae6e12f4068265b6d95eb"
+  integrity sha512-rGSF4pLwvuaH/x4nAS+zP6UNn5YUDWf/TeEU5IoXSZKBbKRNTCI3qMnYXKZgrC0D2KzS2baiOZt1OlqhMu5rnQ==
   optionalDependencies:
-    fsevents "~2.1.2"
+    fsevents "~2.3.1"
 
 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:
-  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.1.2:
+safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
   integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -3664,7 +3733,7 @@
   resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
-"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.7.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==
@@ -3674,20 +3743,24 @@
   resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
   integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
 
-semver@^6.0.0:
+semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, 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.3.2:
-  version "7.3.2"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938"
-  integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==
+  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=
+serialize-javascript@5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-5.0.1.tgz#7886ec848049a462467a97d3d918ebb2aaf934f4"
+  integrity sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==
+  dependencies:
+    randombytes "^2.1.0"
 
 setprototypeof@1.1.0:
   version "1.1.0"
@@ -3709,17 +3782,16 @@
   resolved "https://registry.yarnpkg.com/shady-css-scoped-element/-/shady-css-scoped-element-0.0.2.tgz#c538fcfe2317e979cd02dfec533898b95b4ea8fe"
   integrity sha512-Dqfl70x6JiwYDujd33ZTbtCK0t52E7+H2swdWQNSTzfsolSa6LJHnTpN4T9OpJJEq4bxuzHRLFO9RBcy/UfrMQ==
 
-sinon@^9.0.2:
-  version "9.0.2"
-  resolved "https://registry.yarnpkg.com/sinon/-/sinon-9.0.2.tgz#b9017e24633f4b1c98dfb6e784a5f0509f5fd85d"
-  integrity sha512-0uF8Q/QHkizNUmbK3LRFqx5cpTttEVXudywY9Uwzy8bTfZUhljZ7ARzSxnRHWYWtVTeh4Cw+tTb3iU21FQVO9A==
+sinon@^10.0.0:
+  version "10.0.0"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-10.0.0.tgz#52279f97e35646ff73d23207d0307977c9b81430"
+  integrity sha512-XAn5DxtGVJBlBWYrcYKEhWCz7FLwZGdyvANRyK06419hyEpdT0dMc5A8Vcxg5SCGHc40CsqoKsc1bt1CbJPfNw==
   dependencies:
-    "@sinonjs/commons" "^1.7.2"
+    "@sinonjs/commons" "^1.8.1"
     "@sinonjs/fake-timers" "^6.0.1"
-    "@sinonjs/formatio" "^5.0.1"
-    "@sinonjs/samsam" "^5.0.3"
+    "@sinonjs/samsam" "^5.3.1"
     diff "^4.0.2"
-    nise "^4.0.1"
+    nise "^4.1.0"
     supports-color "^7.1.0"
 
 socket.io-adapter@~1.1.0:
@@ -3808,14 +3880,9 @@
     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==
-
-sprintf-js@~1.0.2:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
-  integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
+  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==
 
 sshpk@^1.7.0:
   version "1.16.1"
@@ -3856,30 +3923,14 @@
     is-fullwidth-code-point "^2.0.0"
     strip-ansi "^4.0.0"
 
-string-width@^3.0.0, string-width@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
-  integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==
+string-width@^4.1.0, string-width@^4.2.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 "^7.0.1"
-    is-fullwidth-code-point "^2.0.0"
-    strip-ansi "^5.1.0"
-
-string.prototype.trimend@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913"
-  integrity sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==
-  dependencies:
-    define-properties "^1.1.3"
-    es-abstract "^1.17.5"
-
-string.prototype.trimstart@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz#14af6d9f34b053f7cfc89b72f8f2ee14b9039a54"
-  integrity sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==
-  dependencies:
-    define-properties "^1.1.3"
-    es-abstract "^1.17.5"
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.0"
 
 strip-ansi@^4.0.0:
   version "4.0.0"
@@ -3888,29 +3939,36 @@
   dependencies:
     ansi-regex "^3.0.0"
 
-strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
+strip-ansi@^5.2.0:
   version "5.2.0"
   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.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532"
+  integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==
+  dependencies:
+    ansi-regex "^5.0.0"
+
 strip-bom@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
   integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=
 
-strip-json-comments@2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
-  integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
+strip-json-comments@3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
+  integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
 
-supports-color@6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.0.0.tgz#76cfe742cf1f41bb9b1c29ad03068c05b4c0e40a"
-  integrity sha512-on9Kwidc1IUQo+bQdhi8+Tijpo0e1SS6RoGo2guUwn5vdaxw8RXOF9Vb2ws+ihWOmh4JnCJOvaziZWP1VABaLg==
+supports-color@8.1.1:
+  version "8.1.1"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c"
+  integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==
   dependencies:
-    has-flag "^3.0.0"
+    has-flag "^4.0.0"
 
 supports-color@^5.3.0:
   version "5.5.0"
@@ -3920,28 +3978,28 @@
     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"
 
-systemjs@^6.3.1:
-  version "6.3.3"
-  resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.3.3.tgz#c0f2bec5cc72d0b36a8b971b1fa32bfc828b50d4"
-  integrity sha512-djQ6mZ4/cWKnVnhAWvr/4+5r7QHnC7WiA8sS9VuYRdEv3wYZYTIIQv8zPT79PdDSUwfX3bgvu5mZ8eTyLm2YQA==
+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==
 
-table-layout@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-1.0.1.tgz#8411181ee951278ad0638aea2f779a9ce42894f9"
-  integrity sha512-dEquqYNJiGwY7iPfZ3wbXDI944iqanTSchrACLL2nOB+1r+h1Nzu2eH+DuPPvWvm5Ry7iAPeFlgEtP5bIp5U7Q==
+table-layout@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-1.0.2.tgz#c4038a1853b0136d63365a734b6931cf4fad4a04"
+  integrity sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==
   dependencies:
     array-back "^4.0.1"
     deep-extend "~0.6.0"
     typical "^5.2.0"
     wordwrapjs "^4.0.0"
 
-terser@^4.6.7:
+terser@^4.6.3, terser@^4.6.7:
   version "4.8.0"
   resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17"
   integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==
@@ -4019,9 +4077,14 @@
     punycode "^2.1.0"
 
 tslib@^1.11.1:
-  version "1.13.0"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
-  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
+  integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
+
+tslib@^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==
 
 tsscmp@1.0.6:
   version "1.0.6"
@@ -4058,16 +4121,11 @@
   resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4"
   integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
 
-typical@^5.0.0, typical@^5.2.0:
+typical@^5.2.0:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066"
   integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==
 
-uglify-js@^3.5.1:
-  version "3.10.0"
-  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.10.0.tgz#397a7e6e31ce820bfd1cb55b804ee140c587a9e7"
-  integrity sha512-Esj5HG5WAyrLIdYU74Z3JdG2PxdIusvj6IWHMtlyESxc7kcDz7zYlYjpnSokn1UbpV0d/QX9fan7gkCNd/9BQA==
-
 ultron@~1.1.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c"
@@ -4106,15 +4164,10 @@
   resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
   integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
 
-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.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"
 
@@ -4173,12 +4226,12 @@
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
   integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
 
-whatwg-fetch@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.1.0.tgz#49d630cdfa308dba7f2819d49d09364f540dbcc6"
-  integrity sha512-pgmbsVWKpH9GxLXZmtdowDIqtb/rvPyjjQv3z9wLcmgWKFHilKnZD3ldgrOlwJoPGOUluQsRPWd52yVkPfmI1A==
+whatwg-fetch@^3.0.0, whatwg-fetch@^3.5.0:
+  version "3.6.2"
+  resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz#dced24f37f2624ed0281725d51d0e2e3fe677f8c"
+  integrity sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==
 
-whatwg-url@^7.0.0:
+whatwg-url@^7.0.0, whatwg-url@^7.1.0:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06"
   integrity sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==
@@ -4187,12 +4240,14 @@
     tr46 "^1.0.1"
     webidl-conversions "^4.0.2"
 
-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.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
+  integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
+  dependencies:
+    isexe "^2.0.0"
 
-which@1.3.1, which@^1.2.1:
+which@^1.2.1:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
   integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
@@ -4212,21 +4267,26 @@
   integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc=
 
 wordwrapjs@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-4.0.0.tgz#9aa9394155993476e831ba8e59fb5795ebde6800"
-  integrity sha512-Svqw723a3R34KvsMgpjFBYCgNOSdcW3mQFK4wIfhGQhtaFVOJmdYoXgi63ne3dTlWgatVcUc7t4HtQ/+bUVIzQ==
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-4.0.1.tgz#d9790bccfb110a0fc7836b5ebce0937b37a8b98f"
+  integrity sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA==
   dependencies:
     reduce-flatten "^2.0.0"
-    typical "^5.0.0"
+    typical "^5.2.0"
 
-wrap-ansi@^5.1.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09"
-  integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==
+workerpool@6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.1.0.tgz#a8e038b4c94569596852de7a8ea4228eefdeb37b"
+  integrity sha512-toV7q9rWNYha963Pl/qyeZ6wG+3nnsyvolaNUS8+R5Wtw6qJPTxIlOP1ZSvcGhEJw+l3HMMmtiNo9Gl61G4GVg==
+
+wrap-ansi@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
   dependencies:
-    ansi-styles "^3.2.0"
-    string-width "^3.0.0"
-    strip-ansi "^5.0.0"
+    ansi-styles "^4.0.0"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.0"
 
 wrappy@1:
   version "1.0.2"
@@ -4247,10 +4307,10 @@
   resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
   integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=
 
-y18n@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
-  integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==
+y18n@^5.0.5:
+  version "5.0.8"
+  resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
+  integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
 
 yallist@^2.1.2:
   version "2.1.2"
@@ -4262,38 +4322,43 @@
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
   integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
 
-yargs-parser@13.1.2, yargs-parser@^13.1.2:
-  version "13.1.2"
-  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38"
-  integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==
-  dependencies:
-    camelcase "^5.0.0"
-    decamelize "^1.2.0"
+yallist@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
+  integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
 
-yargs-unparser@1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-1.6.0.tgz#ef25c2c769ff6bd09e4b0f9d7c605fb27846ea9f"
-  integrity sha512-W9tKgmSn0DpSatfri0nx52Joq5hVXgeLiqR/5G0sZNDoLZFOr/xjBUDcShCOGNsBnEMNo1KAMBkTej1Hm62HTw==
-  dependencies:
-    flat "^4.1.0"
-    lodash "^4.17.15"
-    yargs "^13.3.0"
+yargs-parser@20.2.4:
+  version "20.2.4"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54"
+  integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==
 
-yargs@13.3.2, yargs@^13.3.0:
-  version "13.3.2"
-  resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd"
-  integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==
+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==
+
+yargs-unparser@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb"
+  integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==
   dependencies:
-    cliui "^5.0.0"
-    find-up "^3.0.0"
-    get-caller-file "^2.0.1"
+    camelcase "^6.0.0"
+    decamelize "^4.0.0"
+    flat "^5.0.2"
+    is-plain-obj "^2.1.0"
+
+yargs@16.2.0:
+  version "16.2.0"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"
+  integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==
+  dependencies:
+    cliui "^7.0.2"
+    escalade "^3.1.1"
+    get-caller-file "^2.0.5"
     require-directory "^2.1.1"
-    require-main-filename "^2.0.0"
-    set-blocking "^2.0.0"
-    string-width "^3.0.0"
-    which-module "^2.0.0"
-    y18n "^4.0.0"
-    yargs-parser "^13.1.2"
+    string-width "^4.2.0"
+    y18n "^5.0.5"
+    yargs-parser "^20.2.2"
 
 yeast@0.1.2:
   version "0.1.2"
@@ -4304,3 +4369,8 @@
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f"
   integrity sha512-faQrqNMzcPCHGVC2aaOINk13K+aaBDUPjGWl0teOXywElLjyVAB6Oe2jj62jHYtwsU49jXhScYbvPENK+6zAvQ==
+
+yocto-queue@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
+  integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
diff --git a/proto/BUILD b/proto/BUILD
index 57be265..7aa761d 100644
--- a/proto/BUILD
+++ b/proto/BUILD
@@ -4,6 +4,7 @@
 proto_library(
     name = "cache_proto",
     srcs = ["cache.proto"],
+    deps = [":entities_proto"],
 )
 
 java_proto_library(
diff --git a/proto/cache.proto b/proto/cache.proto
index 7924cbd..292a225 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -18,7 +18,9 @@
 
 option java_package = "com.google.gerrit.server.cache.proto";
 
-// Serialized form of com.google.gerrit.server.change.CHangeKindCacheImpl.Key.
+import "proto/entities.proto";
+
+// Serialized form of com.google.gerrit.server.change.ChangeKindCacheImpl.Key.
 // Next ID: 4
 message ChangeKindKeyProto {
   bytes prior = 1;
@@ -76,7 +78,7 @@
 // Instead, we just take the tedious yet simple approach of having a "has_foo"
 // field for each nullable field "foo", indicating whether or not foo is null.
 //
-// Next ID: 24
+// Next ID: 27
 message ChangeNotesStateProto {
   // Effectively required, even though the corresponding ChangeNotesState field
   // is optional, since the field is only absent when NoteDb is disabled, in
@@ -140,11 +142,9 @@
 
   repeated string hashtag = 5;
 
-  // Raw PatchSet proto as produced by PatchSetProtoConverter.
-  repeated bytes patch_set = 6;
+  repeated devtools.gerritcodereview.PatchSet patch_set = 6;
 
-  // Raw PatchSetApproval proto as produced by PatchSetApprovalProtoConverter.
-  repeated bytes approval = 7;
+  repeated devtools.gerritcodereview.PatchSetApproval approval = 7;
 
   // Next ID: 4
   message ReviewerSetEntryProto {
@@ -184,8 +184,7 @@
   // com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord.
   repeated string submit_record = 14;
 
-  // Raw ChangeMessage proto as produced by ChangeMessageProtoConverter.
-  repeated bytes change_message = 15;
+  repeated devtools.gerritcodereview.ChangeMessage change_message = 15;
 
   // JSON produced from com.google.gerrit.entities.Comment.
   repeated string published_comment = 16;
@@ -218,7 +217,15 @@
     string operation = 3;
     string reason = 4;
   }
+  // Only includes the most recent attention set update for each user.
   repeated AttentionSetUpdateProto attention_set_update = 23;
+
+  // Includes all attention set updates.
+  repeated AttentionSetUpdateProto all_attention_set_update = 24;
+
+  // Epoch millis.
+  int64 merged_on_millis = 25;
+  bool has_merged_on = 26;
 }
 
 // Serialized form of com.google.gerrit.server.query.change.ConflictKey
@@ -279,6 +286,29 @@
   repeated ExternalGroupProto external_group = 1;
 }
 
+// Serialized key for com.google.gerrit.server.account.GroupCacheImpl.
+// Next ID: 3
+message GroupKeyProto {
+  string uuid = 1;
+  bytes revision = 2;
+}
+
+
+// Serialized form of com.google.gerrit.entities.InternalGroup.
+// Next ID: 11
+message InternalGroupProto {
+  int32 id = 1;
+  string name = 2;
+  string description = 3;
+  string owner_group_uuid = 4;
+  bool is_visible_to_all = 5;
+  string group_uuid = 6;
+  int64 created_on = 7;
+  repeated int32 members_ids = 8;
+  repeated string subgroup_uuids = 9;
+  bytes ref_state = 10;
+}
+
 // Key for com.google.gerrit.server.git.PureRevertCache.
 // Next ID: 4
 message PureRevertKeyProto {
@@ -435,6 +465,7 @@
   int32 max_positive = 16;
   bool can_override = 17;
   repeated string ref_patterns = 18;
+  bool copy_all_scores_if_list_of_files_did_not_change = 19;
 }
 
 // Serialized form of com.google.gerrit.server.project.ConfiguredMimeTypes.
@@ -500,3 +531,145 @@
   bytes global_config_revision = 3; // Hash of All-Projects-projects.config. This
                                     // will only be populated for All-Projects.
 }
+
+// Serialized form of com.google.gerrit.server.comment.CommentContextCacheImpl.Key
+// Next ID: 7
+message CommentContextKeyProto {
+  string project = 1;
+  string change_id = 2;
+  int32 patchset = 3;
+  string commentId = 4;
+
+  // hashed with the murmur3_128 hash function
+  string path_hash = 5;
+
+  int32 context_padding = 6;
+}
+
+// Serialized form of a list of com.google.gerrit.extensions.common.ContextLineInfo
+// Next ID: 3
+message AllCommentContextProto {
+  message CommentContextProto {
+    int32 line_number = 1;
+    string context_line = 2;
+  }
+  repeated CommentContextProto context = 1;
+  string content_type = 2;
+}
+
+// Serialized key for
+// com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey
+// Next ID: 5
+message GitModifiedFilesKeyProto {
+  string project = 1;
+  bytes a_tree = 2; // SHA-1 hash of the left git tree ID in the diff
+  bytes b_tree = 3; // SHA-1 hash of the right git tree ID in the diff
+  int32 rename_score = 4;
+}
+
+// Serialized key for
+// com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey
+// Next ID: 5
+message ModifiedFilesKeyProto {
+  string project = 1;
+  bytes a_commit = 2; // SHA-1 hash of the left commit ID in the diff
+  bytes b_commit = 3; // SHA-1 hash of the right commit ID in the diff
+  int32 rename_score = 4;
+}
+
+// Serialized form of com.google.gerrit.server.patch.gitdiff.ModifiedFile
+// Next ID: 4
+message ModifiedFileProto {
+  string change_type = 1; // ENUM as string
+  string old_path = 2;
+  string new_path = 3;
+}
+
+// Serialized form of a collection of
+// com.google.gerrit.server.patch.gitdiff.ModifiedFile
+// Next ID: 2
+message ModifiedFilesProto {
+  repeated ModifiedFileProto modifiedFile = 1;
+}
+
+// Serialized form of a collection of
+// com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.Key
+// Next ID: 8
+message GitFileDiffKeyProto {
+  string project = 1;
+  bytes a_tree = 2;
+  bytes b_tree = 3;
+  string file_path = 4;
+  int32 rename_score = 5;
+  string diff_algorithm = 6; // ENUM as string
+  string whitepsace = 7; // ENUM as string
+}
+
+// Serialized form of com.google.gerrit.server.patch.gitfilediff.GitFileDiff
+// Next ID: 11
+message GitFileDiffProto {
+  message Edit {
+    int32 begin_a = 1;
+    int32 end_a = 2;
+    int32 begin_b = 3;
+    int32 end_b = 4;
+  }
+  repeated Edit edits = 1;
+  string file_header = 2;
+  string old_path = 3;
+  string new_path = 4;
+  bytes old_id = 5;
+  bytes new_id = 6;
+  string old_mode = 7; // ENUM as string
+  string new_mode = 8; // ENUM as string
+  string change_type = 9; // ENUM as string
+  string patch_type = 10; // ENUM as string
+}
+
+// Serialized form of
+// com.google.gerrit.server.patch.fileDiff.FileDiffCacheKey
+// Next ID: 8
+message FileDiffKeyProto {
+  string project = 1;
+  bytes old_commit = 2;
+  bytes new_commit = 3;
+  string file_path = 4;
+  int32 rename_score = 5;
+  string diff_algorithm = 6;
+  string whitespace = 7;
+}
+
+// Serialized form of
+// com.google.gerrit.server.patch.filediff.FileDiffOutput
+// Next ID: 12
+message FileDiffOutputProto {
+  // Next ID: 5
+  message Edit {
+    int32 begin_a = 1;
+    int32 end_a = 2;
+    int32 begin_b = 3;
+    int32 end_b = 4;
+  }
+  // Serialized form  of com.google.gerrit.server.patch.filediff.TaggedEdit
+  // Next ID: 3
+  message TaggedEdit {
+    Edit edit = 1;
+    bool due_to_rebase = 2;
+  }
+  // Next ID: 3
+  message ComparisonType {
+    int32 parent_num = 1;
+    bool auto_merge = 2;
+  }
+  string old_path = 1;
+  string new_path = 2;
+  string change_type = 3;
+  string patch_type = 4;
+  repeated string header_lines = 5;
+  int64 size = 6;
+  int64 size_delta = 7;
+  repeated TaggedEdit edits = 8;
+  bytes old_commit = 9;
+  bytes new_commit = 10;
+  ComparisonType comparison_type = 11;
+}
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 000f4e2..11717fb 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -16,7 +16,7 @@
 
 {namespace com.google.gerrit.httpd.raw}
 
-{template .Index}
+{template Index}
   {@param canonicalPath: ?}
   {@param staticResourcePath: ?}
   {@param gerritInitialData: /** {string} map of REST endpoint to response for startup. */ ?}
@@ -107,6 +107,7 @@
       <link rel="preload" href="{$staticResourcePath}/bower_components/highlightjs/highlight.min.js" as="script"/>
     {/if}
     <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/comments" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+    <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/comments?enable-context=true&context-padding=3" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
     <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/robotcomments" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
     {if $userIsAuthenticated}
       <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/drafts" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
@@ -171,5 +172,5 @@
 
   // Load gr-app.js after <gr-app ...> tag because gr-router expects that
   // <gr-app ...> already exists in the document when script is executed.
-  <script src="{$staticResourcePath}/elements/gr-app.js"></script>{\n}
+  <script src="{$staticResourcePath}/elements/gr-app.js" crossorigin="anonymous"></script>{\n}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/Abandoned.soy b/resources/com/google/gerrit/server/mail/Abandoned.soy
index d5aac0e..9148fa1 100644
--- a/resources/com/google/gerrit/server/mail/Abandoned.soy
+++ b/resources/com/google/gerrit/server/mail/Abandoned.soy
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.Abandoned}
 
 /**
  * The .Abandoned template will determine the contents of the email related to a
  * change being abandoned.
  */
-{template .Abandoned kind="text"}
+{template Abandoned kind="text"}
   {@param change: ?}
   {@param coverLetter: ?}
   {@param email: ?}
diff --git a/resources/com/google/gerrit/server/mail/AbandonedHtml.soy b/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
index 9ad996e..b4e5de8 100644
--- a/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
+++ b/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
@@ -14,9 +14,11 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.AbandonedHtml}
 
-{template .AbandonedHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template AbandonedHtml}
   {@param coverLetter: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -26,7 +28,7 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
diff --git a/resources/com/google/gerrit/server/mail/AddKey.soy b/resources/com/google/gerrit/server/mail/AddKey.soy
index 8b609cf..319db05 100644
--- a/resources/com/google/gerrit/server/mail/AddKey.soy
+++ b/resources/com/google/gerrit/server/mail/AddKey.soy
@@ -14,13 +14,15 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.AddKey}
+
+import * as mailTemplate from 'com/google/gerrit/server/mail/NoReplyFooter.soy';
 
 /**
  * The .AddKey template will determine the contents of the email related to
  * adding a new SSH or GPG key to an account.
  */
-{template .AddKey kind="text"}
+{template AddKey kind="text"}
   {@param email: ?}
   One or more new {$email.keyType} keys have been added to Gerrit Code Review at
   {sp}{$email.gerritHost}:
@@ -64,5 +66,5 @@
   browser window instead.
 
   {\n}
-  {call .NoReplyFooter /}
+  {call mailTemplate.NoReplyFooter /}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
index ed4f435..c356a95 100644
--- a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
+++ b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
@@ -14,9 +14,11 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.AddKeyHtml}
 
-{template .AddKeyHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/NoReplyFooterHtml.soy';
+
+{template AddKeyHtml}
   {@param email: ?}
   <p>
     One or more new {$email.keyType} keys have been added to Gerrit Code Review
@@ -57,5 +59,5 @@
     {/if}.
   </p>
 
-  {call .NoReplyFooterHtml /}
+  {call mailTemplate.NoReplyFooterHtml /}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy b/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy
index 5ea41b2..fcadb55 100644
--- a/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy
+++ b/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.AddToAttentionSet}
 
 /**
  * The .AddToAttentionSet template will determine the contents of the email related to a
  * user being added to the attention set.
  */
-{template .AddToAttentionSet kind="text"}
+{template AddToAttentionSet kind="text"}
   {@param change: ?}
   {@param coverLetter: ?}
   {@param email: ?}
diff --git a/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy b/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy
index bac180a..f550bb1 100644
--- a/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy
+++ b/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy
@@ -14,9 +14,11 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.AddToAttentionSetHtml}
 
-{template .AddToAttentionSetHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template AddToAttentionSetHtml}
   {@param coverLetter: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -33,11 +35,11 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
   {if $coverLetter}
     <div style="white-space:pre-wrap">{$coverLetter}</div>
   {/if}
-{/template}
\ No newline at end of file
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/ChangeFooter.soy b/resources/com/google/gerrit/server/mail/ChangeFooter.soy
index a8170ca..c5b9e4a 100644
--- a/resources/com/google/gerrit/server/mail/ChangeFooter.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeFooter.soy
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.ChangeFooter}
 
 /**
  * The .ChangeFooter template will determine the contents of the footer text
  * that will be appended to ALL emails related to changes.
  */
-{template .ChangeFooter kind="text"}
+{template ChangeFooter kind="text"}
   {@param email: ?}
   --{sp}
   {\n}
diff --git a/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy b/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
index b619c53..5a22acb 100644
--- a/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
@@ -14,9 +14,9 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.ChangeFooterHtml}
 
-{template .ChangeFooterHtml}
+{template ChangeFooterHtml}
   {@param change: ?}
   {@param email: ?}
   {if $email.changeUrl or $email.settingsUrl}
diff --git a/resources/com/google/gerrit/server/mail/ChangeHeader.soy b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
index fde69f1..5dfe671 100644
--- a/resources/com/google/gerrit/server/mail/ChangeHeader.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
@@ -14,9 +14,9 @@
  * limitations under the License.
 */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.ChangeHeader}
 
-{template .ChangeHeader kind="text"}
+{template ChangeHeader kind="text"}
   {@param attentionSet: ?}
   {if $attentionSet}
     Attention is currently required from:{sp}
diff --git a/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
index ea12455..191737f 100644
--- a/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
@@ -15,9 +15,9 @@
 */
 
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.ChangeHeaderHtml}
 
-{template .ChangeHeaderHtml}
+{template ChangeHeaderHtml}
   {@param attentionSet: ?}
   {if $attentionSet}
     <p> Attention is currently required from:{sp}
diff --git a/resources/com/google/gerrit/server/mail/ChangeSubject.soy b/resources/com/google/gerrit/server/mail/ChangeSubject.soy
index 7fcd213..28d04d0 100644
--- a/resources/com/google/gerrit/server/mail/ChangeSubject.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeSubject.soy
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.ChangeSubject}
 
 /**
  * The .ChangeSubject template will determine the contents of the email subject
  * line for ALL emails related to changes.
  */
-{template .ChangeSubject kind="text"}
+{template ChangeSubject kind="text"}
   {@param branch: ?}
   {@param change: ?}
   {@param shortProjectName: ?}
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index 893ef6f..4b923e6 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.Comment}
 
 /**
  * The .Comment template will determine the contents of the email related to a
  * user submitting comments on changes.
  */
-{template .Comment kind="text"}
+{template Comment kind="text"}
   {@param change: ?}
   {@param coverLetter: ?}
   {@param email: ?}
diff --git a/resources/com/google/gerrit/server/mail/CommentFooter.soy b/resources/com/google/gerrit/server/mail/CommentFooter.soy
index 3998438..854687c 100644
--- a/resources/com/google/gerrit/server/mail/CommentFooter.soy
+++ b/resources/com/google/gerrit/server/mail/CommentFooter.soy
@@ -14,12 +14,12 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.CommentFooter}
 
 /**
  * The .CommentFooter template will determine the contents of the footer text
  * that will be appended to emails related to a user submitting comments on
  * changes.
  */
-{template .CommentFooter kind="text"}
+{template CommentFooter kind="text"}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy b/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
index 033c1b1..cad0973 100644
--- a/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
+++ b/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.CommentFooterHtml}
 
-{template .CommentFooterHtml}
+{template CommentFooterHtml}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/CommentHtml.soy b/resources/com/google/gerrit/server/mail/CommentHtml.soy
index 21fee18..a120cea 100644
--- a/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -14,9 +14,11 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.CommentHtml}
 
-{template .CommentHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template CommentHtml}
   {@param commentFiles: ?}
   {@param commentCount: ?}
   {@param email: ?}
@@ -71,7 +73,9 @@
   {/let}
 
   {if $patchSetCommentBlocks}
-    {call .WikiFormat}{param content: $patchSetCommentBlocks /}{/call}
+    {call mailTemplate.WikiFormat}
+      {param content: $patchSetCommentBlocks /}
+    {/call}
   {/if}
 
   {if length($labels) > 0}
@@ -97,7 +101,7 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
@@ -146,11 +150,13 @@
               {if length($comment.lines) > 1}
                 <p>
                   <blockquote style="{$blockquoteStyle}">
-                    {call .Pre}{param content kind="html"}
-                      {for $line in $comment.lines}
-                        {$line}{\n}
-                      {/for}
-                    {/param}{/call}
+                    {call mailTemplate.Pre}
+                      {param content kind="html"}
+                        {for $line in $comment.lines}
+                          {$line}{\n}
+                        {/for}
+                      {/param}
+                    {/call}
                   </blockquote>
                 </p>
               {/if}
@@ -163,7 +169,9 @@
                 </p>
               {/if}
 
-              {call .WikiFormat}{param content: $comment.messageBlocks /}{/call}
+              {call mailTemplate.WikiFormat}
+                {param content: $comment.messageBlocks /}
+              {/call}
             </li>
           {/for}
         </ul>
diff --git a/resources/com/google/gerrit/server/mail/DeleteKey.soy b/resources/com/google/gerrit/server/mail/DeleteKey.soy
index 30548c8..0957dc6 100644
--- a/resources/com/google/gerrit/server/mail/DeleteKey.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteKey.soy
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.DeleteKey}
 
 /**
  * The .DeleteKey template will determine the contents of the email related to
  * deleting a SSH or GPG key.
  */
-{template .DeleteKey kind="text"}
+{template DeleteKey kind="text"}
   {@param email: ?}
   One or more {$email.keyType} keys have been deleted on Gerrit Code Review at
   {sp}{$email.gerritHost}:
diff --git a/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy b/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
index 1ab3955..fea6785 100644
--- a/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
@@ -14,9 +14,9 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.DeleteKeyHtml}
 
-{template .DeleteKeyHtml}
+{template DeleteKeyHtml}
   {@param email: ?}
   <p>
     One or more {$email.keyType} keys have been deleted on Gerrit Code Review
diff --git a/resources/com/google/gerrit/server/mail/DeleteReviewer.soy b/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
index 3310249..a3ed3ee 100644
--- a/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.DeleteReviewer}
 
 /**
  * The .DeleteReviewer template will determine the contents of the email related
  * to removal of a reviewer (and the reviewer's votes) from reviews.
  */
-{template .DeleteReviewer kind="text"}
+{template DeleteReviewer kind="text"}
   {@param change: ?}
   {@param coverLetter: ?}
   {@param email: ?}
diff --git a/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy b/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
index 54720fe..76a9199 100644
--- a/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
@@ -14,9 +14,11 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.DeleteReviewerHtml}
 
-{template .DeleteReviewerHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template DeleteReviewerHtml}
   {@param email: ?}
   {@param fromName: ?}
   <p>
@@ -35,7 +37,7 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/DeleteVote.soy b/resources/com/google/gerrit/server/mail/DeleteVote.soy
index 0ee5454..c2e3e6d 100644
--- a/resources/com/google/gerrit/server/mail/DeleteVote.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteVote.soy
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.DeleteVote}
 
 /**
  * The .DeleteVote template will determine the contents of the email related
  * to removing votes on changes.
  */
-{template .DeleteVote kind="text"}
+{template DeleteVote kind="text"}
   {@param change: ?}
   {@param coverLetter: ?}
   {@param email: ?}
diff --git a/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy b/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
index 3a82927..bff9212 100644
--- a/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
@@ -14,9 +14,11 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.DeleteVoteHtml}
 
-{template .DeleteVoteHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template DeleteVoteHtml}
   {@param coverLetter: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -26,7 +28,7 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
diff --git a/resources/com/google/gerrit/server/mail/Footer.soy b/resources/com/google/gerrit/server/mail/Footer.soy
index 7483cd9..fc1952f 100644
--- a/resources/com/google/gerrit/server/mail/Footer.soy
+++ b/resources/com/google/gerrit/server/mail/Footer.soy
@@ -14,14 +14,14 @@
  * limitations under the License.
 */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.Footer}
 
 /**
  * The .Footer template will determine the contents of the footer text
  * appended to the end of all outgoing emails after the ChangeFooter and
  * CommentFooter.
  */
-{template .Footer kind="text"}
+{template Footer kind="text"}
   {@param footers: ?}
   {for $footer in $footers}
     {$footer}{\n}
diff --git a/resources/com/google/gerrit/server/mail/FooterHtml.soy b/resources/com/google/gerrit/server/mail/FooterHtml.soy
index ce934d3..9a36837 100644
--- a/resources/com/google/gerrit/server/mail/FooterHtml.soy
+++ b/resources/com/google/gerrit/server/mail/FooterHtml.soy
@@ -14,9 +14,9 @@
  * limitations under the License.
 */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.FooterHtml}
 
-{template .FooterHtml}
+{template FooterHtml}
   {@param footers: ?}
   {\n}
   {\n}
diff --git a/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy b/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
index 38e679e..49fbccb 100644
--- a/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
+++ b/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.HttpPasswordUpdate}
 
 /**
  * The .HttpPasswordUpdate template will determine the contents of the email related to
  * adding, changing or deleting the HTTP password.
  */
-{template .HttpPasswordUpdate kind="text"}
+{template HttpPasswordUpdate kind="text"}
   {@param email: ?}
   The HTTP password was {$email.operation} on Gerrit Code Review at
   {sp}{$email.gerritHost}.
diff --git a/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy b/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
index 3c4594c..3f88a6f 100644
--- a/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
+++ b/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
@@ -14,9 +14,9 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.HttpPasswordUpdateHtml}
 
-{template .HttpPasswordUpdateHtml}
+{template HttpPasswordUpdateHtml}
   {@param email: ?}
   <p>
     The HTTP password was {$email.operation} on Gerrit Code Review
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
index 16c5c8d..1241665 100644
--- a/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
@@ -14,9 +14,11 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.InboundEmailRejection}
 
-{template .InboundEmailRejectionFooter kind="text"}
+import * as noReplyFooter from 'com/google/gerrit/server/mail/NoReplyFooter.soy';
+
+{template InboundEmailRejectionFooter kind="text"}
   {\n}
   {\n}
   Thus, no actions were taken by Gerrit in response to this email,
@@ -24,7 +26,7 @@
   {\n}
   This email was sent in response to an email coming from this address.
   In case you did not send Gerrit an email, feel free to ignore this.
-  {call .NoReplyFooter /}
+  {call noReplyFooter.NoReplyFooter /}
 {/template}
 
 /**
@@ -32,39 +34,39 @@
  * to warning users of error in inbound emails
  */
 
-{template .InboundEmailRejection_PARSING_ERROR kind="text"}
+{template InboundEmailRejection_PARSING_ERROR kind="text"}
   Gerrit Code Review was unable to parse your email.{\n}
   This might be because your email did not quote Gerrit's email,
   because you are using an unsupported email client,
   or because of a bug.
-  {call .InboundEmailRejectionFooter /}
+  {call InboundEmailRejectionFooter /}
 {/template}
 
-{template .InboundEmailRejection_UNKNOWN_ACCOUNT kind="text"}
+{template InboundEmailRejection_UNKNOWN_ACCOUNT kind="text"}
   Gerrit Code Review was unable to match your email to an account.{\n}
   This may happen if several accounts are linked to this email address.
-  {call .InboundEmailRejectionFooter /}
+  {call InboundEmailRejectionFooter /}
 {/template}
 
-{template .InboundEmailRejection_INACTIVE_ACCOUNT kind="text"}
+{template InboundEmailRejection_INACTIVE_ACCOUNT kind="text"}
   Your account on this Gerrit Code Review instance is marked as inactive,
   so your email has been ignored. {\n}
   If you think this is an error, please contact your Gerrit instance administrator.
   {\n}{\n}
   This email was sent in response to an email coming from this address.
   In case you did not send Gerrit an email, feel free to ignore this.
-  {call .NoReplyFooter /}
+  {call noReplyFooter.NoReplyFooter /}
 {/template}
 
-{template .InboundEmailRejection_INTERNAL_EXCEPTION kind="text"}
+{template InboundEmailRejection_INTERNAL_EXCEPTION kind="text"}
   Gerrit Code Review encountered an internal exception and was unable to fulfil your request.
   {\n}
   This might be caused by an ongoing maintenance or a data corruption.
-  {call .InboundEmailRejectionFooter /}
+  {call InboundEmailRejectionFooter /}
 {/template}
 
-{template .InboundEmailRejection_COMMENT_REJECTED kind="text"}
+{template InboundEmailRejection_COMMENT_REJECTED kind="text"}
   Gerrit Code Review rejected one or more comments because they did not pass validation, or
   because the maximum number of comments per change would be exceeded.
-  {call .InboundEmailRejectionFooter /}
+  {call InboundEmailRejectionFooter /}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
index 8762e10..6937d13 100644
--- a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
@@ -14,10 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.InboundEmailRejectionHtml}
+
+import * as noReplyFooter from 'com/google/gerrit/server/mail/NoReplyFooter.soy';
+import * as noReplyFooterHtml from 'com/google/gerrit/server/mail/NoReplyFooterHtml.soy';
 
 
-{template .InboundEmailRejectionFooterHtml}
+{template InboundEmailRejectionFooterHtml}
   <p>
     Thus, no actions were taken by Gerrit in response to this email,
     and you should use the Gerrit website to continue.
@@ -25,7 +28,7 @@
   <p>
     In case you did not send Gerrit an email, feel free to ignore this.
   </p>
-  {call .NoReplyFooterHtml /}
+  {call noReplyFooterHtml.NoReplyFooterHtml /}
 {/template}
 
 /**
@@ -33,7 +36,7 @@
  * to warning users of error in inbound emails
  */
 
-{template .InboundEmailRejectionHtml_PARSING_ERROR}
+{template InboundEmailRejectionHtml_PARSING_ERROR}
   <p>
     Gerrit Code Review was unable to parse your email.
   </p>
@@ -42,20 +45,20 @@
     because you are using an unsupported email client,
     or because of a bug.
   </p>
-  {call .InboundEmailRejectionFooterHtml /}
+  {call InboundEmailRejectionFooterHtml /}
 {/template}
 
-{template .InboundEmailRejectionHtml_UNKNOWN_ACCOUNT}
+{template InboundEmailRejectionHtml_UNKNOWN_ACCOUNT}
   <p>
     Gerrit Code Review was unable to match your email to an account.
   </p>
   <p>
     This may happen if several accounts are linked to this email address.
   </p>
-  {call .InboundEmailRejectionFooterHtml /}
+  {call InboundEmailRejectionFooterHtml /}
 {/template}
 
-{template .InboundEmailRejectionHtml_INACTIVE_ACCOUNT}
+{template InboundEmailRejectionHtml_INACTIVE_ACCOUNT}
   <p>
     Your account on this Gerrit Code Review instance is marked as inactive,
     so your email has been ignored.
@@ -66,23 +69,23 @@
   <p>
     In case you did not send Gerrit an email, feel free to ignore this.
   </p>
-  {call .NoReplyFooter /}
+  {call noReplyFooter.NoReplyFooter /}
 {/template}
 
-{template .InboundEmailRejectionHtml_INTERNAL_EXCEPTION}
+{template InboundEmailRejectionHtml_INTERNAL_EXCEPTION}
   <p>
     Gerrit Code Review encountered an internal exception and was unable to fulfil your request.
   </p>
   <p>
     This might be caused by an ongoing maintenance or a data corruption.
   <p>
-  {call .InboundEmailRejectionFooterHtml /}
+  {call InboundEmailRejectionFooterHtml /}
 {/template}
 
-{template .InboundEmailRejectionHtml_COMMENT_REJECTED}
+{template InboundEmailRejectionHtml_COMMENT_REJECTED}
   <p>
     Gerrit Code Review rejected one or more comments because they did not pass validation, or
     because the maximum number of comments per change would be exceeded.
   </p>
-  {call .InboundEmailRejectionFooterHtml /}
+  {call InboundEmailRejectionFooterHtml /}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/Merged.soy b/resources/com/google/gerrit/server/mail/Merged.soy
index 899d1c0..998610c 100644
--- a/resources/com/google/gerrit/server/mail/Merged.soy
+++ b/resources/com/google/gerrit/server/mail/Merged.soy
@@ -15,13 +15,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.Merged}
 
 /**
  * The .Merged template will determine the contents of the email related to
  * a change successfully merged to the head.
  */
-{template .Merged kind="text"}
+{template Merged kind="text"}
   {@param change: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -39,4 +39,5 @@
     {$email.unifiedDiff}
     {\n}
   {/if}
+  {$email.stickyApprovalDiff}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/MergedHtml.soy b/resources/com/google/gerrit/server/mail/MergedHtml.soy
index f0a47c7..1f75a04 100644
--- a/resources/com/google/gerrit/server/mail/MergedHtml.soy
+++ b/resources/com/google/gerrit/server/mail/MergedHtml.soy
@@ -14,9 +14,11 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.MergedHtml}
 
-{template .MergedHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template MergedHtml}
   {@param diffLines: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -26,15 +28,20 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
   <div style="white-space:pre-wrap">{$email.approvals}</div>
 
-  {call .Pre}{param content: $email.changeDetail /}{/call}
+  {call mailTemplate.Pre}
+    {param content: $email.changeDetail /}
+  {/call}
 
   {if $email.includeDiff}
-    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
+    {call mailTemplate.UnifiedDiff}
+      {param diffLines: $diffLines /}
+    {/call}
   {/if}
+  <div style="white-space:pre-wrap">{$email.stickyApprovalDiff}</div>
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/NewChange.soy b/resources/com/google/gerrit/server/mail/NewChange.soy
index fa447e9..0fe8835 100644
--- a/resources/com/google/gerrit/server/mail/NewChange.soy
+++ b/resources/com/google/gerrit/server/mail/NewChange.soy
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.NewChange}
 
 /**
  * The .NewChange template will determine the contents of the email related to a
  * user submitting a new change for review.
  */
-{template .NewChange kind="text"}
+{template NewChange kind="text"}
   {@param change: ?}
   {@param email: ?}
   {@param ownerName: ?}
diff --git a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
index e16b213..dbb3d8a 100644
--- a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -14,9 +14,11 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.NewChangeHtml}
 
-{template .NewChangeHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template NewChangeHtml}
   {@param diffLines: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -41,20 +43,26 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
-  {call .Pre}{param content: $email.changeDetail /}{/call}
+  {call mailTemplate.Pre}
+    {param content: $email.changeDetail /}
+  {/call}
 
   {if $email.sshHost}
-    {call .Pre}{param content kind="html"}
-      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
-          {sp}{$patchSet.refName}
-    {/param}{/call}
+    {call mailTemplate.Pre}
+      {param content kind="html"}
+        git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
+        {sp}{$patchSet.refName}
+      {/param}
+    {/call}
   {/if}
 
   {if $email.includeDiff}
-    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
+    {call mailTemplate.UnifiedDiff}
+      {param diffLines: $diffLines /}
+    {/call}
   {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/NoReplyFooter.soy b/resources/com/google/gerrit/server/mail/NoReplyFooter.soy
index 1443100..304a5bc 100644
--- a/resources/com/google/gerrit/server/mail/NoReplyFooter.soy
+++ b/resources/com/google/gerrit/server/mail/NoReplyFooter.soy
@@ -14,9 +14,9 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.NoReplyFooter}
 
-{template .NoReplyFooter kind="text"}
+{template NoReplyFooter kind="text"}
   {\n}
   This is a send-only email address.  Replies to this message will not be read
   or answered.
diff --git a/resources/com/google/gerrit/server/mail/NoReplyFooterHtml.soy b/resources/com/google/gerrit/server/mail/NoReplyFooterHtml.soy
index 93df527..6d2103c 100644
--- a/resources/com/google/gerrit/server/mail/NoReplyFooterHtml.soy
+++ b/resources/com/google/gerrit/server/mail/NoReplyFooterHtml.soy
@@ -14,9 +14,9 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.NoReplyFooterHtml}
 
-{template .NoReplyFooterHtml}
+{template NoReplyFooterHtml}
   <p>
     This is a send-only email address.  Replies to this message will not be read
     or answered.
diff --git a/resources/com/google/gerrit/server/mail/Private.soy b/resources/com/google/gerrit/server/mail/Private.soy
index 510f15e..7920c21 100644
--- a/resources/com/google/gerrit/server/mail/Private.soy
+++ b/resources/com/google/gerrit/server/mail/Private.soy
@@ -23,7 +23,7 @@
 /**
  * Private template to generate "View Change" buttons.
  */
-{template .ViewChangeButton}
+{template ViewChangeButton}
   {@param email: ?}
   <a href="{$email.changeUrl}">View Change</a>
 {/template}
@@ -31,7 +31,7 @@
 /**
  * Private template to render PRE block with consistent font-sizing.
  */
-{template .Pre}
+{template Pre}
   {@param content: ?}
   {let $preStyle kind="css"}
     font-family: monospace,monospace; // Use this to avoid browsers scaling down
@@ -54,7 +54,7 @@
  * This mechanism encodes as little structure as possible in order to depend on
  * the Soy autoescape mechanism for all of the content.
  */
-{template .WikiFormat}
+{template WikiFormat}
   {@param content: ?}
   {let $blockquoteStyle kind="css"}
     border-left: 1px solid #aaa;
@@ -72,10 +72,10 @@
       <p style="{$pStyle}">{$block.text|changeNewlineToBr}</p>
     {elseif $block.type == 'quote'}
       <blockquote style="{$blockquoteStyle}">
-        {call .WikiFormat}{param content: $block.quotedBlocks /}{/call}
+        {call WikiFormat}{param content: $block.quotedBlocks /}{/call}
       </blockquote>
     {elseif $block.type == 'pre'}
-      {call .Pre}{param content: $block.text /}{/call}
+      {call Pre}{param content: $block.text /}{/call}
     {elseif $block.type == 'list'}
       <ul>
         {for $item in $block.items}
@@ -86,7 +86,7 @@
   {/for}
 {/template}
 
-{template .UnifiedDiff}
+{template UnifiedDiff}
   {@param diffLines: ?}
   {let $addStyle kind="css"}
     color: hsl(120, 100%, 40%);
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
index ee03de0..273f52f 100644
--- a/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.RegisterNewEmail}
 
 /**
  * The .RegisterNewEmail template will determine the contents of the email
  * related to registering new email accounts.
  */
-{template .RegisterNewEmail kind="text"}
+{template RegisterNewEmail kind="text"}
   {@param email: ?}
   Welcome to Gerrit Code Review at {$email.gerritHost}.{\n}
 
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
index 033d145..7d6cd23 100644
--- a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
@@ -14,9 +14,9 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.RegisterNewEmailHtml}
 
-{template .RegisterNewEmailHtml}
+{template RegisterNewEmailHtml}
   {@param email: ?}
   <p>
     Welcome to Gerrit Code Review at {$email.gerritHost}.
@@ -45,4 +45,4 @@
     This is a send-only email address.  Replies to this message will not
     be read or answered.
   </p>
-{/template}
\ No newline at end of file
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy
index f116adb..b33f908 100644
--- a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy
+++ b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.RemoveFromAttentionSet}
 
 /**
  * The .RemoveFromAttentionSet template will determine the contents of the email related to a
  * user being added to the attention set.
  */
-{template .RemoveFromAttentionSet kind="text"}
+{template RemoveFromAttentionSet kind="text"}
   {@param change: ?}
   {@param coverLetter: ?}
   {@param email: ?}
diff --git a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy
index 55eef13..b27fef8 100644
--- a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy
@@ -14,9 +14,11 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.RemoveFromAttentionSetHtml}
 
-{template .RemoveFromAttentionSetHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template RemoveFromAttentionSetHtml}
   {@param coverLetter: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -33,11 +35,11 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
   {if $coverLetter}
     <div style="white-space:pre-wrap">{$coverLetter}</div>
   {/if}
-{/template}
\ No newline at end of file
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
index bb84cf1..2647572 100644
--- a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.ReplacePatchSet}
 
 /**
  * The .ReplacePatchSet template will determine the contents of the email
  * related to a user submitting a new patchset for a change.
  */
-{template .ReplacePatchSet kind="text"}
+{template ReplacePatchSet kind="text"}
   {@param change: ?}
   {@param email: ?}
   {@param fromEmail: ?}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
index 96cba5f..4916a4a 100644
--- a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
@@ -14,9 +14,11 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.ReplacePatchSetHtml}
 
-{template .ReplacePatchSetHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template ReplacePatchSetHtml}
   {@param change: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -35,16 +37,20 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
-  {call .Pre}{param content: $email.changeDetail /}{/call}
+  {call mailTemplate.Pre}
+    {param content: $email.changeDetail /}
+  {/call}
 
   {if $email.sshHost}
-    {call .Pre}{param content kind="html"}
-      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}{sp}
-          {$patchSet.refName}
-    {/param}{/call}
+    {call mailTemplate.Pre}
+      {param content kind="html"}
+        git pull ssh:{print '//'}{$email.sshHost}/{$projectName}{sp}
+        {$patchSet.refName}
+      {/param}
+    {/call}
   {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/Restored.soy b/resources/com/google/gerrit/server/mail/Restored.soy
index 0ec65b30..6bc76ee 100644
--- a/resources/com/google/gerrit/server/mail/Restored.soy
+++ b/resources/com/google/gerrit/server/mail/Restored.soy
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.Restored}
 
 /**
  * The .Restored template will determine the contents of the email related to a
  * change being restored.
  */
-{template .Restored kind="text"}
+{template Restored kind="text"}
   {@param change: ?}
   {@param coverLetter: ?}
   {@param email: ?}
diff --git a/resources/com/google/gerrit/server/mail/RestoredHtml.soy b/resources/com/google/gerrit/server/mail/RestoredHtml.soy
index bcd358f..49e8924 100644
--- a/resources/com/google/gerrit/server/mail/RestoredHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RestoredHtml.soy
@@ -14,9 +14,11 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.RestoredHtml}
 
-{template .RestoredHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template RestoredHtml}
   {@param email: ?}
   {@param fromName: ?}
   <p>
@@ -25,7 +27,7 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/Reverted.soy b/resources/com/google/gerrit/server/mail/Reverted.soy
index 32a65c6..15a5fb7 100644
--- a/resources/com/google/gerrit/server/mail/Reverted.soy
+++ b/resources/com/google/gerrit/server/mail/Reverted.soy
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.Reverted}
 
 /**
  * The .Reverted template will determine the contents of the email related
  * to a change being reverted.
  */
-{template .Reverted kind="text"}
+{template Reverted kind="text"}
   {@param change: ?}
   {@param coverLetter: ?}
   {@param email: ?}
diff --git a/resources/com/google/gerrit/server/mail/RevertedHtml.soy b/resources/com/google/gerrit/server/mail/RevertedHtml.soy
index 69260ad..8bc911b 100644
--- a/resources/com/google/gerrit/server/mail/RevertedHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RevertedHtml.soy
@@ -14,9 +14,11 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.RevertedHtml}
 
-{template .RevertedHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template RevertedHtml}
   {@param email: ?}
   {@param fromName: ?}
   <p>
@@ -25,7 +27,7 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/SetAssignee.soy b/resources/com/google/gerrit/server/mail/SetAssignee.soy
index 1fdf690..83aa580 100644
--- a/resources/com/google/gerrit/server/mail/SetAssignee.soy
+++ b/resources/com/google/gerrit/server/mail/SetAssignee.soy
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.SetAssignee}
 
 /**
  * The .SetAssignee template will determine the contents of the email related
  * to a user being assigned to a change.
  */
-{template .SetAssignee kind="text"}
+{template SetAssignee kind="text"}
   {@param change: ?}
   {@param email: ?}
   {@param fromName: ?}
diff --git a/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy b/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
index 1826314..5435cab 100644
--- a/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
+++ b/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
@@ -14,9 +14,11 @@
  * limitations under the License.
  */
 
-{namespace com.google.gerrit.server.mail.template}
+{namespace com.google.gerrit.server.mail.template.SetAssigneeHtml}
 
-{template .SetAssigneeHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template SetAssigneeHtml}
   {@param diffLines: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -29,20 +31,26 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
-  {call .Pre}{param content: $email.changeDetail /}{/call}
+  {call mailTemplate.Pre}
+    {param content: $email.changeDetail /}
+  {/call}
 
   {if $email.sshHost}
-    {call .Pre}{param content kind="html"}
-      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
-          {sp}{$patchSet.refName}
-    {/param}{/call}
+    {call mailTemplate.Pre}
+      {param content kind="html"}
+        git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
+        {sp}{$patchSet.refName}
+      {/param}
+    {/call}
   {/if}
 
   {if $email.includeDiff}
-    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
+    {call mailTemplate.UnifiedDiff}
+      {param diffLines: $diffLines /}
+    {/call}
   {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 41566c8..6ab682c 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -232,6 +232,7 @@
 toml = text/x-toml
 tpl = text/x-smarty
 ts = application/typescript
+tsx = text/tsx
 ttcn = text/x-ttcn
 ttcnpp = text/x-ttcn
 ttcn3 = text/x-ttcn
diff --git a/resources/log4j.properties b/resources/log4j.properties
index 28c0ee4..39246b3 100644
--- a/resources/log4j.properties
+++ b/resources/log4j.properties
@@ -21,6 +21,7 @@
 # Silence non-critical messages from MINA SSHD.
 #
 log4j.logger.org.apache.mina=WARN
+log4j.logger.org.apache.sshd.client=WARN
 log4j.logger.org.apache.sshd.common=WARN
 log4j.logger.org.apache.sshd.server=WARN
 log4j.logger.org.apache.sshd.common.keyprovider.FileKeyPairProvider=INFO
diff --git a/tools/bzl/BUILD b/tools/bzl/BUILD
index 7febbac..8f63d08 100644
--- a/tools/bzl/BUILD
+++ b/tools/bzl/BUILD
@@ -7,4 +7,5 @@
 sh_test(
     name = "always_pass_test",
     srcs = ["always_pass_test.sh"],
+    tags = ["no_rbe"],
 )
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index dcc4739..c3186ab 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -441,103 +441,40 @@
     """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, srcs = [], deps = [], assets = None, plugin_name = None, **kwargs):
-    """Bundles plugin dependencies for deployment.
+def polygerrit_plugin(name, app, plugin_name = None):
+    """Produces plugin file set with minified javascript.
 
-    This rule bundles all Polymer elements and JS dependencies into .html and .js files.
-    Run-time dependencies (e.g. JS libraries loaded after plugin starts) should be provided using "assets" property.
-    Output of this rule is a FileSet with "${name}_fs", with deploy artifacts in "plugins/${name}/static".
+    This rule minifies a plugin javascript file, potentially renames it, and produces a file set.
+    Output of this rule is a FileSet with "${plugin_name}.js".
 
     Args:
       name: String, rule name.
-      app: String, the main or root source file.
-      assets: Fileset, additional files to be used by plugin in runtime, exported to "plugins/${name}/static".
+      app: String, the main or root source file. This must be single JavaScript file.
       plugin_name: String, plugin name. ${name} is used if not provided.
     """
     if not plugin_name:
         plugin_name = name
 
-    html_plugin = app.endswith(".html")
-    srcs = srcs if app in srcs else srcs + [app]
-
-    if html_plugin:
-        # Combines all .js and .html files into foo_combined.js and foo_combined.html
-        _bundle_rule(
-            name = name + "_combined",
-            app = app,
-            srcs = srcs,
-            deps = deps,
-            pkg = native.package_name(),
-            **kwargs
-        )
-        js_srcs = [name + "_combined.js"]
-    else:
-        js_srcs = srcs
-
-    native.filegroup(
-        name = name + "-src-fg",
-        srcs = js_srcs,
-    )
-
     terser_minified(
-        name = name + ".min",
+        name = plugin_name + ".min",
         sourcemap = False,
-        src = name + "-src-fg",
+        src = app,
     )
 
-    if html_plugin:
-        native.genrule(
-            name = name + "_rename_html",
-            srcs = [name + "_combined.html"],
-            outs = [plugin_name + ".html"],
-            cmd = "sed 's/<script src=\"" + name + "_combined.js\"/<script src=\"" + plugin_name + ".js\"/g' $(SRCS) > $(OUTS)",
-            output_to_bindir = True,
-        )
-    else:
-        # For polymer 3 migration, we will only have js plugins, in case server side
-        # is still asking for *.html, we still want to create a html placeholder just to load the js
-        # TODO(taoalpha): this should be cleaned up once polymer 3 plugins are the only ones gerrit supports
-        native.genrule(
-            name = name + "_rename_html",
-            outs = [plugin_name + ".html"],
-            cmd = "echo \"<script src='" + plugin_name + ".js'></script>\" > $(OUTS)",
-            output_to_bindir = True,
-        )
-
     native.genrule(
         name = name + "_rename_js",
-        srcs = [name + ".min"],
+        srcs = [plugin_name + ".min"],
         outs = [plugin_name + ".js"],
         cmd = "cp $< $@",
         output_to_bindir = True,
     )
 
-    static_files = [plugin_name + ".js", plugin_name + ".html"]
-
-    if assets:
-        nested, direct = [], []
-        for x in assets:
-            target = nested if "/" in x else direct
-            target.append(x)
-
-        static_files += direct
-
-        if nested:
-            native.genrule(
-                name = name + "_copy_assets",
-                srcs = assets,
-                outs = [f.split("/")[-1] for f in nested],
-                cmd = "cp $(SRCS) $(@D)",
-                output_to_bindir = True,
-            )
-            static_files.append(":" + name + "_copy_assets")
-
     native.filegroup(
         name = name,
-        srcs = static_files,
+        srcs = [plugin_name + ".js"],
     )
 
-def gerrit_js_bundle(name, srcs, entry_point):
+def gerrit_js_bundle(name, entry_point, srcs = []):
     """Produces a Gerrit JavaScript bundle archive.
 
     This rule bundles and minifies the javascript files of a frontend plugin and
diff --git a/tools/coverage.sh b/tools/coverage.sh
index c92d5cf..b20de31 100755
--- a/tools/coverage.sh
+++ b/tools/coverage.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
 #
 # Usage
 #
@@ -7,6 +7,12 @@
 # COVERAGE_CPUS defaults to 2, and the default destination is a temp
 # dir.
 
+bazel_bin=$(which bazelisk 2>/dev/null)
+if [[ -z "$bazel_bin" ]]; then
+    echo "Warning: bazelisk is not installed; falling back to bazel."
+    bazel_bin=bazel
+fi
+
 genhtml=$(which genhtml)
 if [[ -z "${genhtml}" ]]; then
     echo "Install 'genhtml' (contained in the 'lcov' package)"
@@ -22,7 +28,7 @@
 
 # coverage is expensive to run; use --jobs=2 to avoid overloading the
 # machine.
-bazel coverage -k --jobs=${COVERAGE_CPUS:-2} -- ...
+${bazel_bin} coverage -k --jobs=${COVERAGE_CPUS:-2} -- ...
 
 # The coverage data contains filenames relative to the Java root, and
 # genhtml has no logic to search these elsewhere. Workaround this
@@ -33,18 +39,27 @@
 cp -r {java,javatests}/* ${destdir}/java
 
 mkdir -p ${destdir}/plugins
-for plugin in `find plugins/ -maxdepth 1 -type d`
+for plugin in `find plugins/ -mindepth 1 -maxdepth 1 -type d`
 do
   mkdir -p ${destdir}/${plugin}/java
-  cp -r plugins/*/{java,javatests}/* ${destdir}/${plugin}/java
+  if [ -e ${plugin}/java/ ]
+    then cp -r ${plugin}/java/* ${destdir}/${plugin}/java
+  fi
+  if [ -e ${plugin}/javatests/ ]
+    then cp -r ${plugin}/javatests/* ${destdir}/${plugin}/java
+  fi
 
   # for backwards compatibility support plugins with old file structure
   mkdir -p ${destdir}/${plugin}/src/{main,test}/java
-  cp -r plugins/*/src/main/java/* ${destdir}/${plugin}/src/main/java
-  cp -r plugins/*/src/test/java/* ${destdir}/${plugin}/src/test/java
+  if [ -e ${plugin}/src/main/java/ ]
+    then cp -r ${plugin}/src/main/java/* ${destdir}/${plugin}/src/main/java/
+  fi
+  if [ -e ${plugin}/src/test/java/ ]
+    then cp -r ${plugin}/src/test/java/* ${destdir}/${plugin}/src/test/java/
+  fi
 done
 
-base=$(bazel info bazel-testlogs)
+base=$(${bazel_bin} info bazel-testlogs)
 for f in $(find ${base}  -name 'coverage.dat') ; do
   cp $f ${destdir}/$(echo $f| sed "s|${base}/||" | sed "s|/|_|g")
 done
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index e091fc1..61ea4fe 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -43,5 +43,14 @@
 
 classpath_collector(
     name = "autovalue_classpath_collect",
-    deps = ["//lib/auto:auto-value"],
+    deps = [
+        "//lib/auto:auto-value",
+        "@auto-value-annotations//jar",
+        "@auto-value-gson-extension//jar",
+        "@auto-value-gson-factory//jar",
+        "@auto-value-gson-runtime//jar",
+        "@autotransient//jar",
+        "@gson//jar",
+        "@javapoet//jar",
+    ],
 )
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 6cfa858..ef20ace 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -188,6 +188,8 @@
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.junit/src')
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.ssh.jsch/src')
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.ssh.jsch/resources')
+        classpathentry('src', 'modules/jgit/org.eclipse.jgit.ssh.apache/src')
+        classpathentry('src', 'modules/jgit/org.eclipse.jgit.ssh.apache/resources')
 
     def classpathentry(kind, path, src=None, out=None, exported=None, excluding=None):
         e = doc.createElement('classpathentry')
diff --git a/tools/js/BUILD b/tools/js/BUILD
index fedaf7f..1a272e2 100644
--- a/tools/js/BUILD
+++ b/tools/js/BUILD
@@ -1 +1 @@
-exports_files(["run_npm_binary.py"])
+exports_files(["run_npm_binary.py", "eslint-chdir.js"])
diff --git a/tools/js/eslint-chdir.js b/tools/js/eslint-chdir.js
new file mode 100644
index 0000000..5aea704
--- /dev/null
+++ b/tools/js/eslint-chdir.js
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Eslint 7 introduced a breaking change - it uses the current workdir instead
+// of the configuration file directory for resolving relative paths:
+// https://eslint.org/docs/user-guide/migrating-to-7.0.0#base-path-change
+// This file is loaded before the eslint and sets the current directory
+// back to the location of configuration file.
+
+const path = require('path');
+const configParamIndex =
+    process.argv.findIndex(arg => arg === '-c' || arg === '---config');
+if (configParamIndex >= 0 && configParamIndex + 1 < process.argv.length) {
+  const dirName = path.dirname(process.argv[configParamIndex + 1]);
+  process.chdir(dirName);
+}
diff --git a/tools/js/eslint.bzl b/tools/js/eslint.bzl
index 586b1c5..b32e2bc 100644
--- a/tools/js/eslint.bzl
+++ b/tools/js/eslint.bzl
@@ -57,9 +57,11 @@
         config,
         ignore,
         "//tools/js/eslint-rules:eslint-rules-srcs",
+        "//tools/js:eslint-chdir.js",
         eslint_rules_toplevel_file,
     ] + plugins + data
     common_templated_args = [
+        "--node_options=--require=$$(rlocation $(rootpath //tools/js:eslint-chdir.js))",
         "--ext",
         ",".join(extensions),
         "-c",
@@ -85,7 +87,7 @@
             "*_test_require_patch.js",
             "--ignore-pattern",
             "*_test_loader.js",
-            native.package_name(),
+            "./", # Relative to the config file location
         ],
         # Should not run sandboxed.
         tags = [
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 226d571..09a491c 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.3.11-SNAPSHOT</version>
+  <version>3.4.5-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
@@ -47,6 +47,9 @@
       <name>Han-Wen Nienhuys</name>
     </developer>
     <developer>
+      <name>Joerg Zieren</name>
+    </developer>
+    <developer>
       <name>Jacek Centkowski</name>
     </developer>
     <developer>
@@ -79,6 +82,9 @@
     <developer>
       <name>Sven Selberg</name>
     </developer>
+    <developer>
+      <name>Tao Zhou</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index c2f6d6d..ff0082e 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.3.11-SNAPSHOT</version>
+  <version>3.4.5-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
@@ -47,6 +47,9 @@
       <name>Han-Wen Nienhuys</name>
     </developer>
     <developer>
+      <name>Joerg Zieren</name>
+    </developer>
+    <developer>
       <name>Jacek Centkowski</name>
     </developer>
     <developer>
@@ -79,6 +82,9 @@
     <developer>
       <name>Sven Selberg</name>
     </developer>
+    <developer>
+      <name>Tao Zhou</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 056f25e..7c371c7 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.3.11-SNAPSHOT</version>
+  <version>3.4.5-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
@@ -47,6 +47,9 @@
       <name>Han-Wen Nienhuys</name>
     </developer>
     <developer>
+      <name>Joerg Zieren</name>
+    </developer>
+    <developer>
       <name>Jacek Centkowski</name>
     </developer>
     <developer>
@@ -79,6 +82,9 @@
     <developer>
       <name>Sven Selberg</name>
     </developer>
+    <developer>
+      <name>Tao Zhou</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index 0ed5dc5..ce73726 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.3.11-SNAPSHOT</version>
+  <version>3.4.5-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
@@ -47,6 +47,9 @@
       <name>Han-Wen Nienhuys</name>
     </developer>
     <developer>
+      <name>Joerg Zieren</name>
+    </developer>
+    <developer>
       <name>Jacek Centkowski</name>
     </developer>
     <developer>
@@ -79,6 +82,9 @@
     <developer>
       <name>Sven Selberg</name>
     </developer>
+    <developer>
+      <name>Tao Zhou</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/node_tools/node_modules_licenses/BUILD b/tools/node_tools/node_modules_licenses/BUILD
index f28ed35..1437e75 100644
--- a/tools/node_tools/node_modules_licenses/BUILD
+++ b/tools/node_tools/node_modules_licenses/BUILD
@@ -12,7 +12,8 @@
     compiler = "//tools/node_tools:tsc_wrapped-bin",
     tsconfig = "tsconfig.json",
     deps = [
-        "@tools_npm//:node_modules",
+        "@tools_npm//@bazel/typescript",
+        "@tools_npm//@types/node",
     ],
 )
 
diff --git a/tools/node_tools/node_modules_licenses/utils.ts b/tools/node_tools/node_modules_licenses/utils.ts
index 5f8e7b3..e73bf96 100644
--- a/tools/node_tools/node_modules_licenses/utils.ts
+++ b/tools/node_tools/node_modules_licenses/utils.ts
@@ -22,6 +22,15 @@
   process.exit(1);
 }
 
+// Bazel params may be surrounded with quotes
+function removeSurrondedQuotes(str: string): string {
+  return str.startsWith("'") && str.endsWith("'") ?
+      str.slice(1, -1) : str;
+}
+
 export function readMultilineParamFile(path: string): string[] {
-  return fs.readFileSync(path, {encoding: 'utf-8'}).split(/\r?\n/).filter(f => f.length > 0);
+  return fs.readFileSync(path, {encoding: 'utf-8'})
+      .split(/\r?\n/)
+      .filter(f => f.length > 0)
+      .map(removeSurrondedQuotes);
 }
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index 038f191..fc7ac16 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -17,7 +17,7 @@
     "rollup": "^2.3.4",
     "rollup-plugin-node-resolve": "^5.2.0",
     "rollup-plugin-terser": "^5.1.3",
-    "typescript": "3.9.5"
+    "typescript": "4.0.5"
   },
   "devDependencies": {},
   "license": "Apache-2.0",
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index 527868b..4aa7ebb 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -496,10 +496,6 @@
   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"
@@ -969,9 +965,9 @@
   integrity sha512-N33cKXGSqhOYaPiT4xUGsYlPPDwFtQM/6QxJxuMXA/7BcySW+lkn2yigWP7vfs4daiL/7NJNU6DMCqg5N4B+xQ==
 
 "@types/node@^10.1.0":
-  version "10.17.27"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.27.tgz#391cb391c75646c8ad2a7b6ed3bbcee52d1bdf19"
-  integrity sha512-J0oqm9ZfAXaPdwNXMMgAhylw5fhmXkToJd06vuDUSAgEDZ/n/69/69UmyBZbc+zT34UnShuDSBqvim3SPnozJg==
+  version "10.17.42"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.42.tgz#90dd71b26fe4f4e2929df6b07e72ef2e9648a173"
+  integrity sha512-HElxYF7C/MSkuvlaHB2c+82zhXiuO49Cq056Dol8AQuTph7oJtduo2n6J8rFa+YhJyNgQ/Lm20ZaxqD0vxU0+Q==
 
 "@types/node@^10.17.12":
   version "10.17.24"
@@ -7871,9 +7867,9 @@
   integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
 
 tslib@^1.8.1:
-  version "1.13.0"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
-  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
+  integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
 tslib@^1.9.0:
   version "1.10.0"
@@ -7917,10 +7913,10 @@
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
   integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
 
-typescript@3.9.5:
-  version "3.9.5"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.5.tgz#586f0dba300cde8be52dd1ac4f7e1009c1b13f36"
-  integrity sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==
+typescript@4.0.5:
+  version "4.0.5"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.5.tgz#ae9dddfd1069f1cb5beb3ef3b2170dd7c1332389"
+  integrity sha512-ywmr/VrTVCmNTJ6iV2LwIrfG1P+lv6luD8sUJs+2eI9NLGigaN+nUQc13iHqisq7bra9lnmUSYqbJvegraBOPQ==
 
 typical@^2.6.0, typical@^2.6.1:
   version "2.6.1"
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 3c4044b..a759dc3 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -1,5 +1,12 @@
 load("//tools/bzl:maven_jar.bzl", "maven_jar")
-load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
+
+GUAVA_VERSION = "30.1-jre"
+
+GUAVA_BIN_SHA1 = "00d0c3ce2311c9e36e73228da25a6e99b2ab826f"
+
+GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
+
+TESTCONTAINERS_VERSION = "1.15.1"
 
 def declare_nongoogle_deps():
     """loads dependencies that are not used at Google.
@@ -60,12 +67,18 @@
         sha1 = "cb2f351bf4463751201f43bb99865235d5ba07ca",
     )
 
-    SSHD_VERS = "2.4.0"
+    SSHD_VERS = "2.7.0"
 
     maven_jar(
         name = "sshd-osgi",
         artifact = "org.apache.sshd:sshd-osgi:" + SSHD_VERS,
-        sha1 = "fc4551c1eeda35e4671b263297d37d2bca81c4d4",
+        sha1 = "a101aad0f79ad424498098f7e91c39d3d92177c1",
+    )
+
+    maven_jar(
+        name = "sshd-sftp",
+        artifact = "org.apache.sshd:sshd-sftp:" + SSHD_VERS,
+        sha1 = "0c9eff7145e20b338c1dd6aca36ba93ed7c0147c",
     )
 
     maven_jar(
@@ -83,7 +96,7 @@
     maven_jar(
         name = "sshd-mina",
         artifact = "org.apache.sshd:sshd-mina:" + SSHD_VERS,
-        sha1 = "8aa8715d07bd61ad8315df66d43c0c04b1b755c8",
+        sha1 = "22799941ec7bd5170ea890363cb968e400a69c41",
     )
 
     # elasticsearch-rest-client explicitly depends on this version
@@ -125,19 +138,10 @@
         sha1 = "76716d529710fc03d1d429b43e3cedd4419f78d4",
     )
 
-    # When upgrading elasticsearch-rest-client, also upgrade httpcore-nio
-    # and httpasyncclient as necessary. Consider also the other
-    # org.apache.httpcomponents dependencies in ../WORKSPACE.
-    maven_jar(
-        name = "elasticsearch-rest-client",
-        artifact = "org.elasticsearch.client:elasticsearch-rest-client:7.8.1",
-        sha1 = "59feefe006a96a39f83b0dfb6780847e06c1d0a8",
-    )
-
     maven_jar(
         name = "jackson-core",
-        artifact = "com.fasterxml.jackson.core:jackson-core:2.11.3",
-        sha1 = "c2351800432bdbdd8284c3f5a7f0782a352aa84a",
+        artifact = "com.fasterxml.jackson.core:jackson-core:2.12.0",
+        sha1 = "afe52c6947d9939170da7989612cef544115511a",
     )
 
     maven_jar(
@@ -169,38 +173,40 @@
         sha1 = "b66d3bedb14da604828a8693bb24fd78e36b0e9e",
     )
 
-    GUICE_VERS = "4.2.3"
+    maven_jar(
+        name = "guava",
+        artifact = "com.google.guava:guava:" + GUAVA_VERSION,
+        sha1 = GUAVA_BIN_SHA1,
+    )
 
-    GUICE_LIBRARY_SHA256 = "5168f5e7383f978c1b4154ac777b78edd8ac214bb9f9afdb92921c8d156483d3"
+    GUICE_VERS = "5.0.1"
 
-    http_file(
-        name = "guice-library-no-aop",
-        canonical_id = "guice-library-no-aop-" + GUICE_VERS + ".jar-" + GUICE_LIBRARY_SHA256,
-        downloaded_file_path = "guice-library-no-aop.jar",
-        sha256 = GUICE_LIBRARY_SHA256,
-        urls = [
-            "https://repo1.maven.org/maven2/com/google/inject/guice/" +
-            GUICE_VERS +
-            "/guice-" +
-            GUICE_VERS +
-            "-no_aop.jar",
-        ],
+    maven_jar(
+        name = "guice-library",
+        artifact = "com.google.inject:guice:" + GUICE_VERS,
+        sha1 = "0dae7556b441cada2b4f0a2314eb68e1ff423429",
     )
 
     maven_jar(
         name = "guice-assistedinject",
         artifact = "com.google.inject.extensions:guice-assistedinject:" + GUICE_VERS,
-        sha1 = "acbfddc556ee9496293ed1df250cc378f331d854",
+        sha1 = "62e02f2aceb7d90ba354584dacc018c1e94ff01c",
     )
 
     maven_jar(
         name = "guice-servlet",
         artifact = "com.google.inject.extensions:guice-servlet:" + GUICE_VERS,
-        sha1 = "8d6e7e35eac4fb5e7df19c55b3bc23fa51b10a11",
+        sha1 = "f527009d51f172a2e6937bfb55fcb827e2e2386b",
+    )
+
+    # Keep this version of Soy synchronized with the version used in Gitiles.
+    maven_jar(
+        name = "soy",
+        artifact = "com.google.template:soy:2021-02-01",
+        sha1 = "8e833744832ba88059205a1e30e0898f925d8cb5",
     )
 
     # Test-only dependencies below.
-
     maven_jar(
         name = "cglib-3_2",
         artifact = "cglib:cglib-nodep:3.2.6",
@@ -235,8 +241,6 @@
         sha1 = "0f63b3b1da563767d04d2e4d3fc1ae0cdeffebe7",
     )
 
-    TESTCONTAINERS_VERSION = "1.15.1"
-
     maven_jar(
         name = "testcontainers",
         artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
@@ -244,12 +248,6 @@
     )
 
     maven_jar(
-        name = "testcontainers-elasticsearch",
-        artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
-        sha1 = "6b778a270b7529fcb9b7a6f62f3ae9d38544ce2f",
-    )
-
-    maven_jar(
         name = "duct-tape",
         artifact = "org.rnorth.duct-tape:duct-tape:1.0.8",
         sha1 = "92edc22a9ab2f3e17c9bf700aaee377d50e8b530",
@@ -266,3 +264,42 @@
         artifact = "net.java.dev.jna:jna:5.5.0",
         sha1 = "0e0845217c4907822403912ad6828d8e0b256208",
     )
+
+    maven_jar(
+        name = "jimfs",
+        artifact = "com.google.jimfs:jimfs:1.2",
+        sha1 = "48462eb319817c90c27d377341684b6b81372e08",
+    )
+
+    TRUTH_VERS = "1.1"
+
+    maven_jar(
+        name = "truth",
+        artifact = "com.google.truth:truth:" + TRUTH_VERS,
+        sha1 = "6a096a16646559c24397b03f797d0c9d75ee8720",
+    )
+
+    maven_jar(
+        name = "truth-java8-extension",
+        artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
+        sha1 = "258db6eb8df61832c5c059ed2bc2e1c88683e92f",
+    )
+
+    maven_jar(
+        name = "truth-liteproto-extension",
+        artifact = "com.google.truth.extensions:truth-liteproto-extension:" + TRUTH_VERS,
+        sha1 = "bf65afa13aa03330e739bcaa5d795fe0f10fbf20",
+    )
+
+    maven_jar(
+        name = "truth-proto-extension",
+        artifact = "com.google.truth.extensions:truth-proto-extension:" + TRUTH_VERS,
+        sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
+    )
+
+    # JGit's transitive dependencies
+    maven_jar(
+        name = "hamcrest",
+        artifact = "org.hamcrest:hamcrest:2.2",
+        sha1 = "1820c0968dba3a11a1b30669bb1f01978a91dedc",
+    )
diff --git a/tools/platforms/Dockerfile b/tools/platforms/Dockerfile
new file mode 100644
index 0000000..157529c
--- /dev/null
+++ b/tools/platforms/Dockerfile
@@ -0,0 +1,21 @@
+# 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.
+
+FROM gcr.io/cloud-marketplace/google/rbe-ubuntu18-04:latest
+
+# Install Git >=2.18.0
+RUN add-apt-repository ppa:git-core/ppa && \
+    apt-get -y update && \
+    apt-get -y install git && \
+    apt-get clean
diff --git a/tools/remote-bazelrc b/tools/remote-bazelrc
index 0aeb8d5..78b86d2 100644
--- a/tools/remote-bazelrc
+++ b/tools/remote-bazelrc
@@ -25,7 +25,7 @@
 # this higher can make builds faster by allowing more jobs to run in parallel.
 # Setting it too high can result in jobs that timeout, however, while waiting
 # for a remote machine to execute them.
-build:remote --jobs=50
+build:remote --jobs=200
 build:remote --disk_cache=
 
 # Set several flags related to specifying the platform, toolchain and java
@@ -42,9 +42,6 @@
 # Set various strategies so that all actions execute remotely. Mixing remote
 # and local execution will lead to errors unless the toolchain and remote
 # machine exactly match the host machine.
-build:remote --spawn_strategy=remote,sandboxed
-build:remote --strategy=Javac=remote
-build:remote --strategy=Genrule=remote
 build:remote --define=EXECUTOR=remote
 
 # Enable the remote cache so action results can be shared across machines,
@@ -60,7 +57,7 @@
 # Enable authentication. This will pick up application default credentials by
 # default. You can use --auth_credentials=some_file.json to use a service
 # account credential instead.
-build:remote --auth_enabled=true
+build:remote --google_default_credentials
 
 # The following flags enable the remote cache so action results can be shared
 # across machines, developers, and workspaces.
@@ -68,6 +65,3 @@
 build:remote-cache --tls_enabled=true
 build:remote-cache --remote_timeout=3600
 build:remote-cache --auth_enabled=true
-build:remote-cache --spawn_strategy=standalone
-build:remote-cache --strategy=Javac=standalone
-build:remote-cache --strategy=Genrule=standalone
diff --git a/version.bzl b/version.bzl
index c753159..501af3c 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.3.11-SNAPSHOT"
+GERRIT_VERSION = "3.4.5-SNAPSHOT"
diff --git a/yarn.lock b/yarn.lock
index d20d82e..a30f36f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,493 +2,508 @@
 # yarn lockfile v1
 
 
-"@babel/code-frame@^7.0.0":
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8"
-  integrity sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA==
+"@babel/code-frame@7.12.11", "@babel/code-frame@^7.0.0":
+  version "7.12.11"
+  resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz"
+  integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==
   dependencies:
-    "@babel/highlight" "^7.0.0"
+    "@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==
+  dependencies:
+    "@babel/highlight" "^7.12.13"
+
+"@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/core@^7.0.0":
-  version "7.5.4"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.5.4.tgz#4c32df7ad5a58e9ea27ad025c11276324e0b4ddd"
-  integrity sha512-+DaeBEpYq6b2+ZmHx3tHspC+ZRflrvLqwfv8E3hNr5LVQoyBnL8RPKSBCg+rK2W2My9PWlujBiqd0ZPsR9Q6zQ==
+  version "7.13.15"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.13.15.tgz#a6d40917df027487b54312202a06812c4f7792d0"
+  integrity sha512-6GXmNYeNjS2Uz+uls5jalOemgIhnTMeaXo+yBUA72kC2uX/8VW6XyhVIo2L8/q0goKQA3EVKx0KOQpVKSeWadQ==
   dependencies:
-    "@babel/code-frame" "^7.0.0"
-    "@babel/generator" "^7.5.0"
-    "@babel/helpers" "^7.5.4"
-    "@babel/parser" "^7.5.0"
-    "@babel/template" "^7.4.4"
-    "@babel/traverse" "^7.5.0"
-    "@babel/types" "^7.5.0"
-    convert-source-map "^1.1.0"
+    "@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"
-    json5 "^2.1.0"
-    lodash "^4.17.11"
-    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.5.0":
-  version "7.5.0"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.5.0.tgz#f20e4b7a91750ee8b63656073d843d2a736dca4a"
-  integrity sha512-1TTVrt7J9rcG5PMjvO7VEG3FrEoEJNHxumRq66GemPmzboLWtIjjcJgk8rokuAS7IiRSpgVSu5Vb9lc99iJkOA==
+"@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.5.0"
+    "@babel/types" "^7.13.0"
     jsesc "^2.5.1"
-    lodash "^4.17.11"
     source-map "^0.5.0"
-    trim-right "^1.0.1"
 
-"@babel/helper-annotate-as-pure@^7.0.0":
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.0.0.tgz#323d39dd0b50e10c7c06ca7d7638e6864d8c5c32"
-  integrity sha512-3UYcJUj9kvSLbLbUIfQTqzcy5VX7GRZ/CCDrnOaZorFFM01aXp1+GJwuFGV4NDDoAS+mOUyHcO6UD/RfqOks3Q==
+"@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.0.0"
+    "@babel/types" "^7.12.13"
 
-"@babel/helper-builder-binary-assignment-operator-visitor@^7.1.0":
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.1.0.tgz#6b69628dfe4087798e0c4ed98e3d4a6b2fbd2f5f"
-  integrity sha512-qNSR4jrmJ8M1VMM9tibvyRAHXQs2PmaksQF7c1CGJNipfe3D8p+wgNwgso/P2A2r2mdgBWAXljNWR0QRZAMW8w==
+"@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.1.0"
-    "@babel/types" "^7.0.0"
+    "@babel/helper-explode-assignable-expression" "^7.12.13"
+    "@babel/types" "^7.12.13"
 
-"@babel/helper-call-delegate@^7.4.4":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-call-delegate/-/helper-call-delegate-7.4.4.tgz#87c1f8ca19ad552a736a7a27b1c1fcf8b1ff1f43"
-  integrity sha512-l79boDFJ8S1c5hvQvG+rc+wHw6IuH7YldmRKsYtpbawsxURu/paVy57FZMomGK22/JckepaikOkY0MoAmdyOlQ==
+"@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/helper-hoist-variables" "^7.4.4"
-    "@babel/traverse" "^7.4.4"
-    "@babel/types" "^7.4.4"
+    "@babel/compat-data" "^7.13.12"
+    "@babel/helper-validator-option" "^7.12.17"
+    browserslist "^4.14.5"
+    semver "^6.3.0"
 
-"@babel/helper-define-map@^7.4.4":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.4.4.tgz#6969d1f570b46bdc900d1eba8e5d59c48ba2c12a"
-  integrity sha512-IX3Ln8gLhZpSuqHJSnTNBWGDE9kdkTEWl21A/K7PQ00tseBwbqCHTvNLHSBd9M0R5rER4h5Rsvj9vw0R5SieBg==
+"@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-function-name" "^7.1.0"
-    "@babel/types" "^7.4.4"
-    lodash "^4.17.11"
+    "@babel/helper-annotate-as-pure" "^7.12.13"
+    regexpu-core "^4.7.1"
 
-"@babel/helper-explode-assignable-expression@^7.1.0":
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.1.0.tgz#537fa13f6f1674df745b0c00ec8fe4e99681c8f6"
-  integrity sha512-NRQpfHrJ1msCHtKjbzs9YcMmJZOg6mQMmGRB+hbamEdG5PNpaSm95275VD92DvJKuyl0s2sFiDmMZ+EnnvufqA==
+"@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/traverse" "^7.1.0"
-    "@babel/types" "^7.0.0"
+    "@babel/types" "^7.13.0"
 
-"@babel/helper-function-name@^7.1.0":
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz#a0ceb01685f73355d4360c1247f582bfafc8ff53"
-  integrity sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw==
+"@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.0.0"
-    "@babel/template" "^7.1.0"
-    "@babel/types" "^7.0.0"
+    "@babel/helper-get-function-arity" "^7.12.13"
+    "@babel/template" "^7.12.13"
+    "@babel/types" "^7.12.13"
 
-"@babel/helper-get-function-arity@^7.0.0":
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz#83572d4320e2a4657263734113c42868b64e49c3"
-  integrity sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ==
+"@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.0.0"
+    "@babel/types" "^7.12.13"
 
-"@babel/helper-hoist-variables@^7.4.4":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.4.4.tgz#0298b5f25c8c09c53102d52ac4a98f773eb2850a"
-  integrity sha512-VYk2/H/BnYbZDDg39hr3t2kKyifAm1W6zHRfhx8jGjIHpQEBv9dry7oQ2f3+J703TLu69nYdxsovl0XYfcnK4w==
+"@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.4.4"
+    "@babel/types" "^7.13.12"
 
-"@babel/helper-member-expression-to-functions@^7.0.0":
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.0.0.tgz#8cd14b0a0df7ff00f009e7d7a436945f47c7a16f"
-  integrity sha512-avo+lm/QmZlv27Zsi0xEor2fKcqWG56D5ae9dzklpIaY7cQMK5N8VSpaNVPPagiqmy7LrEjK1IWdGMOqPu5csg==
+"@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.0.0"
+    "@babel/types" "^7.13.12"
 
-"@babel/helper-module-imports@^7.0.0":
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.0.0.tgz#96081b7111e486da4d2cd971ad1a4fe216cc2e3d"
-  integrity sha512-aP/hlLq01DWNEiDg4Jn23i+CXxW/owM4WpDLFUbpjxe4NS3BhLVZQ5i7E0ZrxuQ/vwekIeciyamgB1UIYxxM6A==
+"@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/types" "^7.0.0"
+    "@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-transforms@^7.1.0":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.4.4.tgz#96115ea42a2f139e619e98ed46df6019b94414b8"
-  integrity sha512-3Z1yp8TVQf+B4ynN7WoHPKS8EkdTbgAEy0nU0rs/1Kw4pDgmvYH3rz3aI11KgxKCba2cn7N+tqzV1mY2HMN96w==
+"@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/helper-module-imports" "^7.0.0"
-    "@babel/helper-simple-access" "^7.1.0"
-    "@babel/helper-split-export-declaration" "^7.4.4"
-    "@babel/template" "^7.4.4"
-    "@babel/types" "^7.4.4"
-    lodash "^4.17.11"
+    "@babel/types" "^7.12.13"
 
-"@babel/helper-optimise-call-expression@^7.0.0":
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.0.0.tgz#a2920c5702b073c15de51106200aa8cad20497d5"
-  integrity sha512-u8nd9NQePYNQV8iPWu/pLLYBqZBa4ZaY1YWRFMuxrid94wKI1QNt67NEZ7GAe5Kc/0LLScbim05xZFWkAdrj9g==
+"@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/types" "^7.0.0"
+    "@babel/helper-annotate-as-pure" "^7.12.13"
+    "@babel/helper-wrap-function" "^7.13.0"
+    "@babel/types" "^7.13.0"
 
-"@babel/helper-plugin-utils@^7.0.0":
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.0.0.tgz#bbb3fbee98661c569034237cc03967ba99b4f250"
-  integrity sha512-CYAOUCARwExnEixLdB6sDm2dIJ/YgEAKDM1MOeMeZu9Ld/bDgVo8aiWrXwcY7OBh+1Ea2uUcVRcxKk0GJvW7QA==
-
-"@babel/helper-regex@^7.0.0", "@babel/helper-regex@^7.4.4":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.4.4.tgz#a47e02bc91fb259d2e6727c2a30013e3ac13c4a2"
-  integrity sha512-Y5nuB/kESmR3tKjU8Nkn1wMGEx1tjJX076HBMeL3XLQCu6vA/YRzuTW0bbb+qRnXvQGn+d6Rx953yffl8vEy7Q==
+"@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:
-    lodash "^4.17.11"
+    "@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-remap-async-to-generator@^7.1.0":
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.1.0.tgz#361d80821b6f38da75bd3f0785ece20a88c5fe7f"
-  integrity sha512-3fOK0L+Fdlg8S5al8u/hWE6vhufGSn0bN09xm2LXMy//REAF8kDCrYoOBKYmA8m5Nom+sV9LyLCwrFynA8/slg==
+"@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/helper-annotate-as-pure" "^7.0.0"
-    "@babel/helper-wrap-function" "^7.1.0"
-    "@babel/template" "^7.1.0"
-    "@babel/traverse" "^7.1.0"
-    "@babel/types" "^7.0.0"
+    "@babel/types" "^7.13.12"
 
-"@babel/helper-replace-supers@^7.1.0", "@babel/helper-replace-supers@^7.4.4":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.4.4.tgz#aee41783ebe4f2d3ab3ae775e1cc6f1a90cefa27"
-  integrity sha512-04xGEnd+s01nY1l15EuMS1rfKktNF+1CkKmHoErDppjAAZL+IUBZpzT748x262HF7fibaQPhbvWUl5HeSt1EXg==
+"@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/helper-member-expression-to-functions" "^7.0.0"
-    "@babel/helper-optimise-call-expression" "^7.0.0"
-    "@babel/traverse" "^7.4.4"
-    "@babel/types" "^7.4.4"
+    "@babel/types" "^7.12.1"
 
-"@babel/helper-simple-access@^7.1.0":
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.1.0.tgz#65eeb954c8c245beaa4e859da6188f39d71e585c"
-  integrity sha512-Vk+78hNjRbsiu49zAPALxTb+JUQCz1aolpd8osOF16BGnLtseD21nbHgLPGUwrXEurZgiCOUmvs3ExTu4F5x6w==
+"@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/template" "^7.1.0"
-    "@babel/types" "^7.0.0"
+    "@babel/types" "^7.12.13"
 
-"@babel/helper-split-export-declaration@^7.4.4":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.4.4.tgz#ff94894a340be78f53f06af038b205c49d993677"
-  integrity sha512-Ro/XkzLf3JFITkW6b+hNxzZ1n5OQ80NvIUdmHspih1XAhtN3vPTuUFT4eQnela+2MaZ5ulH+iyP513KJrxbN7Q==
-  dependencies:
-    "@babel/types" "^7.4.4"
+"@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-wrap-function@^7.1.0":
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz#c4e0012445769e2815b55296ead43a958549f6fa"
-  integrity sha512-o9fP1BZLLSrYlxYEYyl2aS+Flun5gtjTIG8iln+XuEzQTs0PLagAGSXUcqruJwD5fM48jzIEggCKpIfWTcR7pQ==
-  dependencies:
-    "@babel/helper-function-name" "^7.1.0"
-    "@babel/template" "^7.1.0"
-    "@babel/traverse" "^7.1.0"
-    "@babel/types" "^7.2.0"
+"@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/helpers@^7.5.4":
-  version "7.5.4"
-  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.5.4.tgz#2f00608aa10d460bde0ccf665d6dcf8477357cf0"
-  integrity sha512-6LJ6xwUEJP51w0sIgKyfvFMJvIb9mWAfohJp0+m6eHJigkFdcH8duZ1sfhn0ltJRzwUIT/yqqhdSfRpCpL7oow==
+"@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/template" "^7.4.4"
-    "@babel/traverse" "^7.5.0"
-    "@babel/types" "^7.5.0"
+    "@babel/helper-function-name" "^7.12.13"
+    "@babel/template" "^7.12.13"
+    "@babel/traverse" "^7.13.0"
+    "@babel/types" "^7.13.0"
 
-"@babel/highlight@^7.0.0":
-  version "7.5.0"
-  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.5.0.tgz#56d11312bd9248fa619591d02472be6e8cb32540"
-  integrity sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ==
+"@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"
     chalk "^2.0.0"
-    esutils "^2.0.2"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.4.4", "@babel/parser@^7.5.0":
-  version "7.5.0"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.5.0.tgz#3e0713dff89ad6ae37faec3b29dcfc5c979770b7"
-  integrity sha512-I5nW8AhGpOXGCCNYGc+p7ExQIBxRFnS2fd/d862bNOKvmoEPjYPcfIjsfdy0ujagYOIYPczKgD9l3FsgTkAzKA==
+"@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.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-external-helpers/-/plugin-external-helpers-7.2.0.tgz#7f4cb7dee651cd380d2034847d914288467a6be4"
-  integrity sha512-QFmtcCShFkyAsNtdCM3lJPmRe1iB+vPZymlB4LnDIKEBj2yKQLQKtoxXxJ8ePT5fwMl4QGg303p4mB0UsSI2/g==
+  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.0.0"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
 "@babel/plugin-proposal-async-generator-functions@^7.0.0":
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.2.0.tgz#b289b306669dce4ad20b0252889a15768c9d417e"
-  integrity sha512-+Dfo/SCQqrwx48ptLVGLdE39YtWRuKc/Y9I5Fy0P1DDBB9lsAHpjcEJQt+4IifuSOSTLBKJObJqMvaO1pIE8LQ==
+  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.0.0"
-    "@babel/helper-remap-async-to-generator" "^7.1.0"
-    "@babel/plugin-syntax-async-generators" "^7.2.0"
+    "@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.5.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.5.4.tgz#250de35d867ce8260a31b1fdac6c4fc1baa99331"
-  integrity sha512-KCx0z3y7y8ipZUMAEEJOyNi11lMb/FOPUjjB113tfowgw0c16EGYos7worCKBcUAh2oG+OBnoUhsnTSoLpV9uA==
+  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/helper-plugin-utils" "^7.0.0"
-    "@babel/plugin-syntax-object-rest-spread" "^7.2.0"
+    "@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.2.0":
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.2.0.tgz#69e1f0db34c6f5a0cf7e2b3323bf159a76c8cb7f"
-  integrity sha512-1ZrIRBv2t0GSlcwVoQ6VgSLpLgiN/FVQUzt9znxo7v2Ov4jJrs8RY8tv0wvDmFN3qIdMKWrmMMW6yZ0G19MfGg==
+"@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.0.0"
+    "@babel/helper-plugin-utils" "^7.8.0"
 
 "@babel/plugin-syntax-dynamic-import@^7.0.0":
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.2.0.tgz#69c159ffaf4998122161ad8ebc5e6d1f55df8612"
-  integrity sha512-mVxuJ0YroI/h/tbFTPGZR8cv6ai+STMKNBq0f8hFxsxWjl94qqhsb+wXbpNMDPU3cfR1TIsVFzU3nXyZMqyK4w==
+  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.0.0"
+    "@babel/helper-plugin-utils" "^7.8.0"
 
 "@babel/plugin-syntax-import-meta@^7.0.0":
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.2.0.tgz#2333ef4b875553a3bcd1e93f8ebc09f5b9213a40"
-  integrity sha512-Hq6kFSZD7+PHkmBN8bCpHR6J8QEoCuEV/B38AIQscYjgMZkGlXB7cHNFzP5jR4RCh5545yP1ujHdmO7hAgKtBA==
+  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.0.0"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.2.0":
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.2.0.tgz#3b7a3e733510c57e820b9142a6579ac8b0dfad2e"
-  integrity sha512-t0JKGgqk2We+9may3t0xDdmneaXmyxq0xieYcKHxIsrJO64n1OiMWNUtc5gQK1PA0NpdCRrtZp4z+IUaKugrSA==
+"@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.0.0"
+    "@babel/helper-plugin-utils" "^7.8.0"
 
 "@babel/plugin-transform-arrow-functions@^7.0.0":
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz#9aeafbe4d6ffc6563bf8f8372091628f00779550"
-  integrity sha512-ER77Cax1+8/8jCB9fo4Ud161OZzWN5qawi4GusDuRLcDbDG+bIGYY20zb2dfAFdTRGzrfq2xZPvF0R64EHnimg==
+  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.0.0"
+    "@babel/helper-plugin-utils" "^7.13.0"
 
 "@babel/plugin-transform-async-to-generator@^7.0.0":
-  version "7.5.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.5.0.tgz#89a3848a0166623b5bc481164b5936ab947e887e"
-  integrity sha512-mqvkzwIGkq0bEF1zLRRiTdjfomZJDV33AH3oQzHVGkI2VzEmXLpKKOBvEVaFZBJdN0XTyH38s9j/Kiqr68dggg==
+  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.0.0"
-    "@babel/helper-plugin-utils" "^7.0.0"
-    "@babel/helper-remap-async-to-generator" "^7.1.0"
+    "@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.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.2.0.tgz#5d3cc11e8d5ddd752aa64c9148d0db6cb79fd190"
-  integrity sha512-ntQPR6q1/NKuphly49+QiQiTN0O63uOwjdD6dhIjSWBI5xlrbUFh720TIpzBhpnrLfv2tNH/BXvLIab1+BAI0w==
+  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.0.0"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
 "@babel/plugin-transform-block-scoping@^7.0.0":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.4.4.tgz#c13279fabf6b916661531841a23c4b7dae29646d"
-  integrity sha512-jkTUyWZcTrwxu5DD4rWz6rDB5Cjdmgz6z7M7RLXOJyCUkFBawssDGcGh8M/0FTSB87avyJI1HsTwUXp9nKA1PA==
+  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.0.0"
-    lodash "^4.17.11"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
 "@babel/plugin-transform-classes@^7.0.0":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.4.4.tgz#0ce4094cdafd709721076d3b9c38ad31ca715eb6"
-  integrity sha512-/e44eFLImEGIpL9qPxSRat13I5QNRgBLu2hOQJCF7VLy/otSM/sypV1+XaIw5+502RX/+6YaSAPmldk+nhHDPw==
+  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.0.0"
-    "@babel/helper-define-map" "^7.4.4"
-    "@babel/helper-function-name" "^7.1.0"
-    "@babel/helper-optimise-call-expression" "^7.0.0"
-    "@babel/helper-plugin-utils" "^7.0.0"
-    "@babel/helper-replace-supers" "^7.4.4"
-    "@babel/helper-split-export-declaration" "^7.4.4"
+    "@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.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.2.0.tgz#83a7df6a658865b1c8f641d510c6f3af220216da"
-  integrity sha512-kP/drqTxY6Xt3NNpKiMomfgkNn4o7+vKxK2DDKcBG9sHj51vHqMBGy8wbDS/J4lMxnqs153/T3+DmCEAkC5cpA==
+  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.0.0"
+    "@babel/helper-plugin-utils" "^7.13.0"
 
 "@babel/plugin-transform-destructuring@^7.0.0":
-  version "7.5.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.5.0.tgz#f6c09fdfe3f94516ff074fe877db7bc9ef05855a"
-  integrity sha512-YbYgbd3TryYYLGyC7ZR+Tq8H/+bCmwoaxHfJHupom5ECstzbRLTch6gOQbhEY9Z4hiCNHEURgq06ykFv9JZ/QQ==
+  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.0.0"
+    "@babel/helper-plugin-utils" "^7.13.0"
 
 "@babel/plugin-transform-duplicate-keys@^7.0.0":
-  version "7.5.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.5.0.tgz#c5dbf5106bf84cdf691222c0974c12b1df931853"
-  integrity sha512-igcziksHizyQPlX9gfSjHkE2wmoCH3evvD2qR5w29/Dk0SMKE/eOI7f1HhBdNhR/zxJDqrgpoDTq5YSLH/XMsQ==
+  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.0.0"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
 "@babel/plugin-transform-exponentiation-operator@^7.0.0":
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.2.0.tgz#a63868289e5b4007f7054d46491af51435766008"
-  integrity sha512-umh4hR6N7mu4Elq9GG8TOu9M0bakvlsREEC+ialrQN6ABS4oDQ69qJv1VtR3uxlKMCQMCvzk7vr17RHKcjx68A==
+  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.1.0"
-    "@babel/helper-plugin-utils" "^7.0.0"
+    "@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.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.4.4.tgz#0267fc735e24c808ba173866c6c4d1440fc3c556"
-  integrity sha512-9T/5Dlr14Z9TIEXLXkt8T1DU7F24cbhwhMNUziN3hB1AXoZcdzPcTiKGRn/6iOymDqtTKWnr/BtRKN9JwbKtdQ==
+  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.0.0"
+    "@babel/helper-plugin-utils" "^7.13.0"
 
 "@babel/plugin-transform-function-name@^7.0.0":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.4.4.tgz#e1436116abb0610c2259094848754ac5230922ad"
-  integrity sha512-iU9pv7U+2jC9ANQkKeNF6DrPy4GBa4NWQtl6dHB4Pb3izX2JOEvDTFarlNsBj/63ZEzNNIAMs3Qw4fNCcSOXJA==
+  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.1.0"
-    "@babel/helper-plugin-utils" "^7.0.0"
+    "@babel/helper-function-name" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
 "@babel/plugin-transform-instanceof@^7.0.0":
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-instanceof/-/plugin-transform-instanceof-7.2.0.tgz#2ee9dc9b389fa13cf325be10ff8414be0d10aecf"
-  integrity sha512-tw2fb96tpcd5XaJXns19tGKo/SeIUS0exAteHJ/7EP27Bke7RmV/gAftHCf1WKZgKeZOUfzOL7nrXH2HIH9auA==
+  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.0.0"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
 "@babel/plugin-transform-literals@^7.0.0":
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.2.0.tgz#690353e81f9267dad4fd8cfd77eafa86aba53ea1"
-  integrity sha512-2ThDhm4lI4oV7fVQ6pNNK+sx+c/GM5/SaML0w/r4ZB7sAneD/piDJtwdKlNckXeyGK7wlwg2E2w33C/Hh+VFCg==
+  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.0.0"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
 "@babel/plugin-transform-modules-amd@^7.0.0":
-  version "7.5.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.5.0.tgz#ef00435d46da0a5961aa728a1d2ecff063e4fb91"
-  integrity sha512-n20UsQMKnWrltocZZm24cRURxQnWIvsABPJlw/fvoy9c6AgHZzoelAIzajDHAQrDpuKFFPPcFGd7ChsYuIUMpg==
+  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.1.0"
-    "@babel/helper-plugin-utils" "^7.0.0"
-    babel-plugin-dynamic-import-node "^2.3.0"
+    "@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.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.2.0.tgz#b35d4c10f56bab5d650047dad0f1d8e8814b6598"
-  integrity sha512-VMyhPYZISFZAqAPVkiYb7dUe2AsVi2/wCT5+wZdsNO31FojQJa9ns40hzZ6U9f50Jlq4w6qwzdBB2uwqZ00ebg==
+  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.0.0"
-    "@babel/helper-replace-supers" "^7.1.0"
+    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-replace-supers" "^7.12.13"
 
-"@babel/plugin-transform-parameters@^7.0.0":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.4.4.tgz#7556cf03f318bd2719fe4c922d2d808be5571e16"
-  integrity sha512-oMh5DUO1V63nZcu/ZVLQFqiihBGo4OpxJxR1otF50GMeCLiRx5nUdtokd+u9SuVJrvvuIh9OosRFPP4pIPnwmw==
+"@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-call-delegate" "^7.4.4"
-    "@babel/helper-get-function-arity" "^7.0.0"
-    "@babel/helper-plugin-utils" "^7.0.0"
+    "@babel/helper-plugin-utils" "^7.13.0"
 
 "@babel/plugin-transform-regenerator@^7.0.0":
-  version "7.4.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.4.5.tgz#629dc82512c55cee01341fb27bdfcb210354680f"
-  integrity sha512-gBKRh5qAaCWntnd09S8QC7r3auLCqq5DI6O0DlfoyDjslSBVqBibrMdsqO+Uhmx3+BlOmE/Kw1HFxmGbv0N9dA==
+  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.0"
+    regenerator-transform "^0.14.2"
 
 "@babel/plugin-transform-shorthand-properties@^7.0.0":
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.2.0.tgz#6333aee2f8d6ee7e28615457298934a3b46198f0"
-  integrity sha512-QP4eUM83ha9zmYtpbnyjTLAGKQritA5XW/iG9cjtuOI8s1RuL/3V6a3DeSHfKutJQ+ayUfeZJPcnCYEQzaPQqg==
+  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.0.0"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
 "@babel/plugin-transform-spread@^7.0.0":
-  version "7.2.2"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.2.2.tgz#3103a9abe22f742b6d406ecd3cd49b774919b406"
-  integrity sha512-KWfky/58vubwtS0hLqEnrWJjsMGaOeSBn90Ezn5Jeg9Z8KKHmELbP1yGylMlm5N6TPKeY9A2+UaSYLdxahg01w==
+  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.0.0"
+    "@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.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.2.0.tgz#a1e454b5995560a9c1e0d537dfc15061fd2687e1"
-  integrity sha512-KKYCoGaRAf+ckH8gEL3JHUaFVyNHKe3ASNsZ+AlktgHevvxGigoIttrEJb8iKN03Q7Eazlv1s6cx2B2cQ3Jabw==
+  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.0.0"
-    "@babel/helper-regex" "^7.0.0"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
 "@babel/plugin-transform-template-literals@^7.0.0":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.4.4.tgz#9d28fea7bbce637fb7612a0750989d8321d4bcb0"
-  integrity sha512-mQrEC4TWkhLN0z8ygIvEL9ZEToPhG5K7KDW3pzGqOfIGZ28Jb0POUkeWcoz8HnHvhFy6dwAT1j8OzqN8s804+g==
+  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-annotate-as-pure" "^7.0.0"
-    "@babel/helper-plugin-utils" "^7.0.0"
+    "@babel/helper-plugin-utils" "^7.13.0"
 
 "@babel/plugin-transform-typeof-symbol@^7.0.0":
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.2.0.tgz#117d2bcec2fbf64b4b59d1f9819894682d29f2b2"
-  integrity sha512-2LNhETWYxiYysBtrBTqL8+La0jIoQQnIScUJc74OYvUGRmkskNY4EzLCnjHBzdmb38wqtTaixpo1NctEcvMDZw==
+  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.0.0"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
 "@babel/plugin-transform-unicode-regex@^7.0.0":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.4.4.tgz#ab4634bb4f14d36728bf5978322b35587787970f"
-  integrity sha512-il+/XdNw01i93+M9J9u4T7/e/Ue/vWfNZE4IRUQjplu2Mqb/AFTDimkw2tdEdSH50wuQXZAbXSql0UphQke+vA==
+  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-plugin-utils" "^7.0.0"
-    "@babel/helper-regex" "^7.4.4"
-    regexpu-core "^4.5.4"
+    "@babel/helper-create-regexp-features-plugin" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/template@^7.1.0", "@babel/template@^7.4.4":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.4.4.tgz#f4b88d1225689a08f5bc3a17483545be9e4ed237"
-  integrity sha512-CiGzLN9KgAvgZsnivND7rkA+AeJ9JB0ciPOD4U59GKbQP2iQl+olF1l76kJOupqidozfZ32ghwBEJDhnk9MEcw==
+"@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==
   dependencies:
-    "@babel/code-frame" "^7.0.0"
-    "@babel/parser" "^7.4.4"
-    "@babel/types" "^7.4.4"
+    regenerator-runtime "^0.13.4"
 
-"@babel/traverse@^7.0.0", "@babel/traverse@^7.0.0-beta.42", "@babel/traverse@^7.1.0", "@babel/traverse@^7.4.4", "@babel/traverse@^7.5.0":
-  version "7.5.0"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.5.0.tgz#4216d6586854ef5c3c4592dab56ec7eb78485485"
-  integrity sha512-SnA9aLbyOCcnnbQEGwdfBggnc142h/rbqqsXcaATj2hZcegCl903pUD/lfpsNBlBSuWow/YDfRyJuWi2EPR5cg==
+"@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.0.0"
-    "@babel/generator" "^7.5.0"
-    "@babel/helper-function-name" "^7.1.0"
-    "@babel/helper-split-export-declaration" "^7.4.4"
-    "@babel/parser" "^7.5.0"
-    "@babel/types" "^7.5.0"
+    "@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"
-    lodash "^4.17.11"
 
-"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.42", "@babel/types@^7.2.0", "@babel/types@^7.4.4", "@babel/types@^7.5.0":
-  version "7.5.0"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.5.0.tgz#e47d43840c2e7f9105bc4d3a2c371b4d0c7832ab"
-  integrity sha512-UFpDVqRABKsW01bvw7/wSUe56uy6RXM5+VJibVVAybDGxEW25jdwiFJEf7ASvSaC7sN7rbE/l3cLp2izav+CtQ==
+"@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:
-    esutils "^2.0.2"
-    lodash "^4.17.11"
+    "@babel/helper-validator-identifier" "^7.12.11"
+    lodash "^4.17.19"
     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==
+  version "5.4.0"
+  resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.4.0.tgz#04e752a6ea3e684f00879e6683657c4ede72df6e"
+  integrity sha512-jlupaDKxqFS3B1lttOIgkKxirP7v5Qx7KCFtOXO7JxtvYJD/qKtKXEQggTrGKJqLPyiZlNiYimHHGICLSWIZcQ==
   dependencies:
     protobufjs "6.8.8"
     source-map-support "0.5.9"
@@ -524,6 +539,30 @@
   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==
+  dependencies:
+    ajv "^6.12.4"
+    debug "^4.1.1"
+    espree "^7.3.0"
+    globals "^12.1.0"
+    ignore "^4.0.6"
+    import-fresh "^3.2.1"
+    js-yaml "^3.13.1"
+    minimatch "^3.0.4"
+    strip-json-comments "^3.1.1"
+
 "@mrmlnc/readdir-enhanced@^2.2.1":
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"
@@ -532,51 +571,116 @@
     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==
+  dependencies:
+    "@nodelib/fs.stat" "2.0.4"
+    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@^1.1.2":
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b"
   integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==
 
-"@octokit/endpoint@^5.1.0":
-  version "5.2.1"
-  resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-5.2.1.tgz#e5ef98bc4a41fad62b17e71af1a1710f6076b8df"
-  integrity sha512-GoUsRSRhtbCQugRY8eDWg5BnsczUZNq00qArrP7tKPHFmvz2KzJ8DoEq6IAQhLGwAOBHbZQ/Zml3DiaEKAWwkA==
+"@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==
   dependencies:
-    deepmerge "4.0.0"
-    is-plain-object "^3.0.0"
-    universal-user-agent "^2.1.0"
-    url-template "^2.0.8"
+    "@nodelib/fs.scandir" "2.1.4"
+    fastq "^1.6.0"
 
-"@octokit/request-error@^1.0.1", "@octokit/request-error@^1.0.2":
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-1.0.4.tgz#15e1dc22123ba4a9a4391914d80ec1e5303a23be"
-  integrity sha512-L4JaJDXn8SGT+5G0uX79rZLv0MNJmfGa4vb4vy1NnpjSnWDLJRy6m90udGwvMmavwsStgbv2QNkPzzTCMmL+ig==
+"@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@^4.0.1":
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/@octokit/request/-/request-4.1.1.tgz#614262214f48417b4d3b14e047d09a9c8e2f7a09"
-  integrity sha512-LOyL0i3oxRo418EXRSJNk/3Q4I0/NKawTn6H/CQp+wnrG1UFLGu080gSsgnWobhPo5BpUNgSQ5BRk5FOOJhD1Q==
+"@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/endpoint" "^5.1.0"
-    "@octokit/request-error" "^1.0.1"
+    "@octokit/types" "^6.0.3"
     deprecation "^2.0.0"
-    is-plain-object "^3.0.0"
-    node-fetch "^2.3.0"
     once "^1.4.0"
-    universal-user-agent "^2.1.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.28.2"
-  resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-16.28.2.tgz#3fc3b8700046ab29ab1e2a4bdf49f89e94f7ba27"
-  integrity sha512-csuYiHvJ1P/GFDadVn0QhwO83R1+YREjcwCY7ZIezB6aJTRIEidJZj+R7gAkUhT687cqYb4cXTZsDVu9F+Fmug==
+  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/request" "^4.0.1"
+    "@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 "^1.4.0"
+    before-after-hook "^2.0.0"
     btoa-lite "^1.0.0"
     deprecation "^2.0.0"
     lodash.get "^4.4.2"
@@ -584,8 +688,21 @@
     lodash.uniq "^4.5.0"
     octokit-pagination-methods "^1.1.0"
     once "^1.4.0"
-    universal-user-agent "^2.0.0"
-    url-template "^2.0.8"
+    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"
@@ -604,27 +721,27 @@
 
 "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
   version "1.1.2"
-  resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf"
+  resolved "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz"
   integrity sha1-m4sMxmPWaafY9vXQiToU00jzD78=
 
 "@protobufjs/base64@^1.1.2":
   version "1.1.2"
-  resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735"
+  resolved "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz"
   integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==
 
 "@protobufjs/codegen@^2.0.4":
   version "2.0.4"
-  resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb"
+  resolved "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz"
   integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==
 
 "@protobufjs/eventemitter@^1.1.0":
   version "1.1.0"
-  resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70"
+  resolved "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz"
   integrity sha1-NVy8mLr61ZePntCV85diHx0Ga3A=
 
 "@protobufjs/fetch@^1.1.0":
   version "1.1.0"
-  resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45"
+  resolved "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz"
   integrity sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=
   dependencies:
     "@protobufjs/aspromise" "^1.1.1"
@@ -632,41 +749,53 @@
 
 "@protobufjs/float@^1.0.2":
   version "1.0.2"
-  resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1"
+  resolved "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz"
   integrity sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=
 
 "@protobufjs/inquire@^1.1.0":
   version "1.1.0"
-  resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089"
+  resolved "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz"
   integrity sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik=
 
 "@protobufjs/path@^1.1.2":
   version "1.1.2"
-  resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d"
+  resolved "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz"
   integrity sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=
 
 "@protobufjs/pool@^1.1.0":
   version "1.1.0"
-  resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54"
+  resolved "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz"
   integrity sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=
 
 "@protobufjs/utf8@^1.1.0":
   version "1.1.0"
-  resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
+  resolved "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz"
   integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=
 
 "@sindresorhus/is@^0.14.0":
   version "0.14.0"
-  resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
+  resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz"
   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.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421"
+  resolved "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz"
   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"
@@ -681,7 +810,12 @@
   dependencies:
     "@types/babel-types" "*"
 
-"@types/babel-types@*", "@types/babel-types@^6.25.1":
+"@types/babel-types@*":
+  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==
@@ -694,29 +828,39 @@
     "@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.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.17.1"
-  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.17.1.tgz#18fcf61768fb5c30ccc508c21d6fd2e8b3bf7897"
-  integrity sha512-RoX2EZjMiFMjZh9lmYrwgoP9RTpAjSHiJxdp4oidAQVO02T7HER3xj9UKue5534ULWeqVEkujhWcyvUce+d68w==
+  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.2"
-  resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.2.tgz#16e3267e0557aaec0d77651c0ce27b684f4602c1"
-  integrity sha512-VMA1aOXwPEJADlj5ykmYv77YKmbEuAxiLz/+lT6vFIWQ1EA06jF01TytVBAbVTNk0pjfW1Uhw5R5MaEq426N0A==
+  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.1.7"
-  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.1.7.tgz#1b8e33b61a8c09cbe1f85133071baa0dbf9fa71a"
-  integrity sha512-2Y8uPt0/jwjhQ6EiluT0XCri1Dbplr0ZxfFXUz+ye13gaqE8u5gL5ppao1JrUYr9cIip5S6MvQzBS7Kke7U9VA==
+  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"
@@ -731,22 +875,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.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/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"
@@ -755,9 +895,9 @@
     "@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.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" "*"
 
@@ -788,36 +928,38 @@
   resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-0.0.20.tgz#cae698714dd61ebee5ab3f2aeb9a34ba1011735a"
   integrity sha512-6dhZJLbA7aOwkYB2GDGdIqJ20wmHnkDzaxV9PJXe7O02I2dSFTERzRB6JrX6cWKaS+VqhhY7cQUMCbO5kloFUw==
 
-"@types/eslint-visitor-keys@^1.0.0":
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"
-  integrity sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==
-
-"@types/estree@0.0.39":
-  version "0.0.39"
-  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
-  integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
+"@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/express-serve-static-core@*":
-  version "4.17.1"
-  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.1.tgz#82be64a77211b205641e0209096fd3afb62481d3"
-  integrity sha512-9e7jj549ZI+RxY21Cl0t8uBnWyb22HzILupyHZjYEVK//5TT/1bZodU+yUbLnPdoYViBBnNWbxp4zYjGV0zUGw==
+"@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.2"
-  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.2.tgz#a0fb7a23d8855bac31bc01d5a58cadd9b2173e6c"
-  integrity sha512-5mHFNyavtLoJmnusB8OKJ5bshSzw+qkMIBAobLrIM48HJvunFva9mOa6aBwh64lBFyNwBbs0xiEFuj4eU/NjCA==
+  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" "*"
+    "@types/express-serve-static-core" "^4.17.18"
+    "@types/qs" "*"
     "@types/serve-static" "*"
 
 "@types/fast-levenshtein@0.0.1":
@@ -833,11 +975,11 @@
     "@types/minimatch" "*"
 
 "@types/form-data@*":
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/@types/form-data/-/form-data-2.2.1.tgz#ee2b3b8eaa11c0938289953606b745b738c54b1e"
-  integrity sha512-JAMFhOaHIciYVh8fb5/83nmuO/AHwmto+Hq7a9y8FzLDcC1KCU344XDOMEmahnrTFlHjgh4L0WJFczNIX2GxnQ==
+  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:
-    "@types/node" "*"
+    form-data "*"
 
 "@types/freeport@^1.0.19":
   version "1.0.21"
@@ -852,12 +994,11 @@
     "@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.3"
+  resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.3.tgz#e6ba80f36b7daad2c685acd9266382e68985c183"
+  integrity sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w==
   dependencies:
-    "@types/events" "*"
     "@types/minimatch" "*"
     "@types/node" "*"
 
@@ -885,7 +1026,20 @@
     "@types/relateurl" "*"
     "@types/uglify-js" "*"
 
-"@types/inquirer@*", "@types/inquirer@0.0.32":
+"@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=
@@ -899,9 +1053,21 @@
   integrity sha1-byTuSHMdMRaOpRBhDW3RXl/Jxv8=
 
 "@types/json-schema@^7.0.3":
-  version "7.0.5"
-  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.5.tgz#dcce4430e64b443ba8945f0290fb564ad5bac6dd"
-  integrity sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ==
+  version "7.0.7"
+  resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz"
+  integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==
+
+"@types/json5@^0.0.29":
+  version "0.0.29"
+  resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
+  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"
@@ -910,7 +1076,7 @@
 
 "@types/long@^4.0.0":
   version "4.0.1"
-  resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
+  resolved "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz"
   integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==
 
 "@types/merge-stream@^1.0.28":
@@ -920,20 +1086,25 @@
   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.4"
+  resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.4.tgz#f0ec25dbf2f0e4b18647313ac031134ca5b24b21"
+  integrity sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA==
 
 "@types/minimist@^1.2.0":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6"
-  integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=
+  version "1.2.1"
+  resolved "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.1.tgz"
+  integrity sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg==
 
 "@types/mz@0.0.29":
   version "0.0.29"
@@ -950,24 +1121,24 @@
   dependencies:
     "@types/node" "*"
 
-"@types/node@*", "@types/node@^12.0.10":
-  version "12.6.1"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-12.6.1.tgz#d5544f6de0aae03eefbb63d5120f6c8be0691946"
-  integrity sha512-rp7La3m845mSESCgsJePNL/JQyhkOJA6G4vcwvVgkDAwHhGdq5GCumxmPjEk1MZf+8p5ZQAUE7tqgQRQTXN7uQ==
+"@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@^10.1.0":
-  version "10.17.49"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.49.tgz#ecf0b67bab4b84d0ec9b0709db4aac3824a51c4a"
-  integrity sha512-PGaJNs5IZz5XgzwJvL/1zRfZB7iaJ5BydZ8/Picm+lUNYoNO9iVTQkVy5eUh0dZDrx3rBOIs3GCbCRmMuYyqwg==
+  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.3"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-4.9.3.tgz#a24697a8157ab517996afe0c88fa716550ae419a"
-  integrity sha512-Q9eESThBvAbfEzznF1qTAKUoPbJEbK3lTSO0S3mICvmG/vUSZ+HnCtidpuB58Po7CJt5A2goKsDiYScN8d1V4A==
+  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.0"
-  resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
+  resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz"
   integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==
 
 "@types/opn@^3.0.28":
@@ -996,6 +1167,11 @@
   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"
@@ -1035,6 +1211,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"
@@ -1124,9 +1307,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" "*"
@@ -1147,12 +1330,12 @@
   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.9"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.9.tgz#aacf28a85a05ee29a11fb7c3ead935ac56f33e4e"
+  integrity sha512-ZFqF6qa48XsPdjXV5Gsz0Zqmux2PerNd3a/ktL45mHpa19cuMi/cL8tcxdAx497yRh+QtYPuofjT9oWw9P7nkA==
   dependencies:
-    "@types/express-serve-static-core" "*"
-    "@types/mime" "*"
+    "@types/mime" "^1"
+    "@types/node" "*"
 
 "@types/spdy@^3.4.1":
   version "3.4.4"
@@ -1169,21 +1352,21 @@
     "@types/node" "*"
 
 "@types/through@*":
-  version "0.0.29"
-  resolved "https://registry.yarnpkg.com/@types/through/-/through-0.0.29.tgz#72943aac922e179339c651fa34a4428a4d722f93"
-  integrity sha512-9a7C5VHh+1BKblaYiq+7Tfc+EOmjMdZaD1MYtkQjSoxgB69tBjW98ry6SKsi4zEIWztLOMRuL87A3bdT/Fc/4w==
+  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.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.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.0.4"
-  resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.0.4.tgz#96beae23df6f561862a830b4288a49e86baac082"
-  integrity sha512-SudIN9TRJ+v8g5pTG8RRCqfqTMNqgWCKKd3vtynhGzkIIjxaicNAMuY5TRadJ6tzDu3Dotf3ngaMILtmOdmWEQ==
+  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"
 
@@ -1193,11 +1376,9 @@
   integrity sha512-BLStNhP2DFF7funARwTcoD6tetRte8NK3Sc59mn7GNALCN975jOlKX3dGvsFxXr/HwQMxxCuRn9IWB3WQ7odHQ==
 
 "@types/uuid@^3.4.3":
-  version "3.4.5"
-  resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.5.tgz#d4dc10785b497a1474eae0ba7f0cb09c0ddfd6eb"
-  integrity sha512-MNL15wC3EKyw1VLF+RoVO4hJJdk9t/Hlv3rt1OL65Qvuadm4BYo6g9ZJQqoq7X8NBFSsQXgAujWciovh2lpVjA==
-  dependencies:
-    "@types/node" "*"
+  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"
@@ -1218,10 +1399,11 @@
     "@types/vinyl" "*"
 
 "@types/vinyl@*", "@types/vinyl@^2.0.0":
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/@types/vinyl/-/vinyl-2.0.3.tgz#80a6ce362ab5b32a0c98e860748a31bce9bff0de"
-  integrity sha512-hrT6xg16CWSmndZqOTJ6BGIn2abKyTw0B58bI+7ioUoj3Sma6u8ftZ1DTI2yCaJamOVGLOnQWiPH3a74+EaqTA==
+  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":
@@ -1244,58 +1426,148 @@
     "@types/events" "*"
     "@types/inquirer" "*"
 
-"@typescript-eslint/eslint-plugin@2.31.0":
-  version "2.31.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.31.0.tgz#942c921fec5e200b79593c71fafb1e3f57aa2e36"
-  integrity sha512-iIC0Pb8qDaoit+m80Ln/aaeu9zKQdOLF4SHcGLarSeY1gurW6aU4JsOPMjKQwXlw70MvWKZQc6S2NamA8SJ/gg==
+"@typescript-eslint/eslint-plugin@^4.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" "2.31.0"
+    "@typescript-eslint/experimental-utils" "4.21.0"
+    "@typescript-eslint/scope-manager" "4.21.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@2.31.0":
-  version "2.31.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.31.0.tgz#a9ec514bf7fd5e5e82bc10dcb6a86d58baae9508"
-  integrity sha512-MI6IWkutLYQYTQgZ48IVnRXmLR/0Q6oAyJgiOror74arUMh7EWjJkADfirZhRsUMHeLJ85U2iySDwHTSnNi9vA==
+"@typescript-eslint/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==
+  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/typescript-estree" "2.31.0"
+    "@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/parser@2.31.0":
-  version "2.31.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-2.31.0.tgz#beddd4e8efe64995108b229b2862cd5752d40d6f"
-  integrity sha512-uph+w6xUOlyV2DLSC6o+fBDzZ5i7+3/TxAsH4h3eC64tlga57oMb96vVlXoMwjR/nN+xyWlsnxtbDkB46M2EPQ==
+"@typescript-eslint/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/eslint-visitor-keys" "^1.0.0"
-    "@typescript-eslint/experimental-utils" "2.31.0"
-    "@typescript-eslint/typescript-estree" "2.31.0"
-    eslint-visitor-keys "^1.1.0"
+    "@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"
 
-"@typescript-eslint/typescript-estree@2.31.0":
-  version "2.31.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.31.0.tgz#ac536c2d46672aa1f27ba0ec2140d53670635cfd"
-  integrity sha512-vxW149bXFXXuBrAak0eKHOzbcu9cvi6iNcJDzEtOkRwGHxJG15chiAQAwhLOsk+86p9GTr/TziYvw+H9kMaIgA==
+"@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==
   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"
-    eslint-visitor-keys "^1.1.0"
-    glob "^7.1.6"
+
+"@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==
+  dependencies:
+    "@typescript-eslint/types" "4.21.0"
+    "@typescript-eslint/visitor-keys" "4.21.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==
+  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"
     is-glob "^4.0.1"
-    lodash "^4.17.15"
-    semver "^6.3.0"
+    semver "^7.3.2"
     tsutils "^3.17.1"
 
+"@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==
+  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"
+    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==
 
-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==
+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"
@@ -1317,10 +1589,10 @@
   dependencies:
     acorn "^3.0.4"
 
-acorn-jsx@^5.1.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.1.0.tgz#294adb71b57398b0680015f0a38c563ee1db5384"
-  integrity sha512-tMUqwBWfLFbJbizRmEcWSLw6HnFzfdJs2sOJEOwwtVPMoH/0Ay+E703oZz78VSXZiiDcZrQ5XKjPIUQixhmgVw==
+acorn-jsx@^5.3.1:
+  version "5.3.1"
+  resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz"
+  integrity sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==
 
 acorn@^3.0.4:
   version "3.3.0"
@@ -1328,30 +1600,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@^6.1.1:
-  version "6.2.0"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.2.0.tgz#67f0da2fc339d6cfb5d6fb244fd449f33cd8bbe3"
-  integrity sha512-8oe72N3WPMjA+2zVG71Ia0nXZ8DpQH+QyyHO+p06jT8eg8FGG3FbcUIi8KziHlAfheJQZeoqbvq1mQSQHXKYLw==
-
-acorn@^7.1.0:
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c"
-  integrity sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==
+acorn@^7.1.0, acorn@^7.4.0:
+  version "7.4.1"
+  resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz"
+  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"
@@ -1359,24 +1633,24 @@
   dependencies:
     es6-promisify "^5.0.0"
 
-ajv@^6.10.0, ajv@^6.10.2:
-  version "6.10.2"
-  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52"
-  integrity sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==
+ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4:
+  version "6.12.6"
+  resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz"
+  integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
   dependencies:
-    fast-deep-equal "^2.0.1"
+    fast-deep-equal "^3.1.1"
     fast-json-stable-stringify "^2.0.0"
     json-schema-traverse "^0.4.1"
     uri-js "^4.2.2"
 
-ajv@^6.5.5:
-  version "6.10.1"
-  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.1.tgz#ebf8d3af22552df9dd049bfbe50cc2390e823593"
-  integrity sha512-w1YQaVGNC6t2UCPjEawK/vo/dG8OOrVtUmhBT1uJJYxbl5kU2Tj3v6LGqBcsysN1yhuCStJCCA3GqdvKY8sqXQ==
+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==
   dependencies:
-    fast-deep-equal "^2.0.1"
-    fast-json-stable-stringify "^2.0.0"
-    json-schema-traverse "^0.4.1"
+    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:
@@ -1395,27 +1669,27 @@
 
 ansi-align@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.0.tgz#b536b371cf687caaef236c18d3e21fe3797467cb"
+  resolved "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.0.tgz"
   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"
+  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@^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.0"
-  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.0.tgz#a4ce2b33d6b214b7950d8595c212f12ac9cc569d"
-  integrity sha512-EiYhwo0v255HUL6eDyuLrXEkTi7WwVCLAw+SeOQ7M7qdun1z1pum4DEm/nuqIVbPvi9RPPc9k9LbyBv6H0DwVg==
+  version "4.3.2"
+  resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz"
+  integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==
   dependencies:
-    type-fest "^0.8.1"
+    type-fest "^0.21.3"
 
 ansi-regex@^2.0.0:
   version "2.1.1"
@@ -1429,12 +1703,12 @@
 
 ansi-regex@^4.1.0:
   version "4.1.0"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
+  resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz"
   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"
+  resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz"
   integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==
 
 ansi-styles@^2.2.1:
@@ -1442,19 +1716,18 @@
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
   integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=
 
-ansi-styles@^3.2.0, ansi-styles@^3.2.1:
+ansi-styles@^3.2.1:
   version "3.2.1"
-  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
+  resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz"
   integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
   dependencies:
     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==
+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"
+  integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
   dependencies:
-    "@types/color-name" "^1.1.1"
     color-convert "^2.0.1"
 
 ansi-styles@~1.0.0:
@@ -1480,11 +1753,6 @@
   resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56"
   integrity sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY=
 
-aproba@^1.0.3:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
-  integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
-
 archiver-utils@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-2.1.0.tgz#e8a460e94b693c3e3da182a098ca6285ba9249e2"
@@ -1514,17 +1782,9 @@
     tar-stream "^2.1.0"
     zip-stream "^2.1.2"
 
-are-we-there-yet@~1.1.2:
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21"
-  integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==
-  dependencies:
-    delegates "^1.0.0"
-    readable-stream "^2.0.6"
-
 argparse@^1.0.7:
   version "1.0.10"
-  resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
+  resolved "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz"
   integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==
   dependencies:
     sprintf-js "~1.0.2"
@@ -1568,6 +1828,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"
@@ -1578,22 +1843,29 @@
   resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
   integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
 
-array-includes@^3.0.3:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.1.tgz#cdd67e6852bdf9c1215460786732255ed2459348"
-  integrity sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ==
+array-includes@^3.1.1:
+  version "3.1.3"
+  resolved "https://registry.npmjs.org/array-includes/-/array-includes-3.1.3.tgz"
+  integrity sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A==
   dependencies:
+    call-bind "^1.0.2"
     define-properties "^1.1.3"
-    es-abstract "^1.17.0"
+    es-abstract "^1.18.0-next.2"
+    get-intrinsic "^1.1.1"
     is-string "^1.0.5"
 
-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.npmjs.org/array-union/-/array-union-2.1.0.tgz"
+  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"
@@ -1609,13 +1881,14 @@
   resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
   integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=
 
-array.prototype.flat@^1.2.1:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.3.tgz#0de82b426b0318dbfdb940089e38b043d37f6c7b"
-  integrity sha512-gBlRZV0VSmfPIeWfuuy56XZMvbVfbEUnOXUvt3F/eUUUSyzlgLxhEX4YAEpxNAogRGehPSnfXyPtYyKAhkzQhQ==
+array.prototype.flat@^1.2.3:
+  version "1.2.4"
+  resolved "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz"
+  integrity sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg==
   dependencies:
+    call-bind "^1.0.0"
     define-properties "^1.1.3"
-    es-abstract "^1.17.0-next.1"
+    es-abstract "^1.18.0-next.1"
 
 arraybuffer.slice@~0.0.7:
   version "0.0.7"
@@ -1624,7 +1897,7 @@
 
 arrify@^1.0.0, arrify@^1.0.1:
   version "1.0.1"
-  resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
+  resolved "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz"
   integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=
 
 arrify@^2.0.1:
@@ -1649,34 +1922,32 @@
   resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
   integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
 
-astral-regex@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9"
-  integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==
+astral-regex@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz"
+  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-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.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@^2.6.0, async@^2.6.1:
-  version "2.6.2"
-  resolved "https://registry.yarnpkg.com/async/-/async-2.6.2.tgz#18330ea7e6e313887f5d2f2a904bac6fe4dd5381"
-  integrity sha512-H1qVYh1MYhEEFLsP97cVKqCGo7KfCyTt6uEWqsTBr9SO84oK9Uwbyd/yCW+6rKJLHksBNUVWZDAjfS+Ccx0Bbg==
-  dependencies:
-    lodash "^4.17.11"
+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"
@@ -1704,9 +1975,16 @@
   integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
 
 aws4@^1.8.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
-  integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==
+  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"
@@ -1773,10 +2051,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"
 
@@ -1792,15 +2070,15 @@
   dependencies:
     babel-helper-evaluate-path "^0.5.0"
 
-babel-plugin-minify-dead-code-elimination@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-dead-code-elimination/-/babel-plugin-minify-dead-code-elimination-0.5.0.tgz#d23ef5445238ad06e8addf5c1cf6aec835bcda87"
-  integrity sha512-XQteBGXlgEoAKc/BhO6oafUdT4LBa7ARi55mxoyhLHNuA+RlzRmeMAfc31pb/UqU01wBzRc36YqHQzopnkd/6Q==
+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.some "^4.6.0"
+    lodash "^4.17.11"
 
 babel-plugin-minify-flip-comparisons@^0.4.3:
   version "0.4.3"
@@ -1809,11 +2087,12 @@
   dependencies:
     babel-helper-is-void-0 "^0.4.3"
 
-babel-plugin-minify-guarded-expressions@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-guarded-expressions/-/babel-plugin-minify-guarded-expressions-0.4.3.tgz#cc709b4453fd21b1f302877444c89f88427ce397"
-  integrity sha1-zHCbRFP9IbHzAod0RMifiEJ845c=
+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:
@@ -1838,11 +2117,12 @@
   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.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-simplify/-/babel-plugin-minify-simplify-0.5.0.tgz#1f090018afb90d8b54d3d027fd8a4927f243da6f"
-  integrity sha512-TM01J/YcKZ8XIQd1Z3nF2AdWHoDsarjtZ5fWPDksYZNsoOjQ2UO2EWm824Ym6sp127m44gPlLFiO5KFxU8pA5Q==
+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"
@@ -1914,20 +2194,20 @@
   integrity sha1-viQcqBQEAwZ4t0hxcyK4nQyP4oA=
 
 babel-preset-minify@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-preset-minify/-/babel-preset-minify-0.5.0.tgz#e25bb8d3590087af02b650967159a77c19bfb96b"
-  integrity sha512-xj1s9Mon+RFubH569vrGCayA9Fm2GMsCgDRm1Jb8SgctOB7KFcrVc2o8K3YHUyMz+SWP8aea75BoS8YfsXXuiA==
+  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.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.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.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"
@@ -1940,7 +2220,7 @@
     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.isplainobject "^4.0.6"
+    lodash "^4.17.11"
 
 babel-runtime@^6.22.0, babel-runtime@^6.26.0:
   version "6.26.0"
@@ -1991,20 +2271,25 @@
   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.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
+  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, base64-js@^1.0.2:
+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"
@@ -2030,17 +2315,10 @@
   dependencies:
     tweetnacl "^0.14.3"
 
-before-after-hook@^1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-1.4.0.tgz#2b6bf23dca4f32e628fd2747c10a37c74a4b484d"
-  integrity sha512-l5r9ir56nda3qu14nAXIlyq1MmUSs0meCIaFAh8HwkFwP1F8eToOuS3ah2VAHHcY04jaYD7FpJC5JTXHYRbkzg==
-
-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"
+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"
@@ -2048,32 +2326,33 @@
   integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==
 
 binaryextensions@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.1.2.tgz#c83c3d74233ba7674e4f313cb2a2b70f54e94b7c"
-  integrity sha512-xVNN69YGDghOqCCtA6FI7avYrr02mTJjOgB0/f1VPD3pJC8QEvjTKWc4epDx8AqxxA75NI0QpVM2gPJXUbE4Tg==
+  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.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"
@@ -2097,25 +2376,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"
@@ -2123,9 +2405,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"
@@ -2155,23 +2437,23 @@
     term-size "^1.2.0"
     widest-line "^2.0.0"
 
-boxen@^4.2.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/boxen/-/boxen-4.2.0.tgz#e411b62357d6d6d36587c8ac3d5d974daa070e64"
-  integrity sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ==
+boxen@^5.0.0:
+  version "5.0.1"
+  resolved "https://registry.npmjs.org/boxen/-/boxen-5.0.1.tgz"
+  integrity sha512-49VBlw+PrWEF51aCmy7QIteYPIFZxSpvqBdP/2itCPPlJ49kj9zg/XPRFrdkne2W+CfwXUls8exMvu1RysZpKA==
   dependencies:
     ansi-align "^3.0.0"
-    camelcase "^5.3.1"
-    chalk "^3.0.0"
-    cli-boxes "^2.2.0"
-    string-width "^4.1.0"
-    term-size "^2.1.0"
-    type-fest "^0.8.1"
+    camelcase "^6.2.0"
+    chalk "^4.1.0"
+    cli-boxes "^2.2.1"
+    string-width "^4.2.0"
+    type-fest "^0.20.2"
     widest-line "^3.1.0"
+    wrap-ansi "^7.0.0"
 
 brace-expansion@^1.1.7:
   version "1.1.11"
-  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+  resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz"
   integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
   dependencies:
     balanced-match "^1.0.0"
@@ -2202,6 +2484,13 @@
     split-string "^3.0.2"
     to-regex "^3.0.1"
 
+braces@^3.0.1:
+  version "3.0.2"
+  resolved "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz"
+  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"
@@ -2217,10 +2506,21 @@
   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.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"
 
@@ -2254,16 +2554,16 @@
 
 buffer-from@^1.0.0:
   version "1.1.1"
-  resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
+  resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz"
   integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
 
-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"
 
 busboy@^0.2.11:
   version "0.2.14"
@@ -2298,9 +2598,14 @@
     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.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912"
+  resolved "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz"
   integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==
   dependencies:
     clone-response "^1.0.2"
@@ -2311,19 +2616,35 @@
     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"
+  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=
-
 callsites@^3.0.0:
   version "3.1.0"
-  resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
+  resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz"
   integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
 
 camel-case@3.0.x:
@@ -2344,7 +2665,7 @@
 
 camelcase-keys@^6.2.2:
   version "6.2.2"
-  resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-6.2.2.tgz#5e755d6ba51aa223ec7d3d52f25778210f9dc3c0"
+  resolved "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz"
   integrity sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==
   dependencies:
     camelcase "^5.3.1"
@@ -2361,15 +2682,15 @@
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
   integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=
 
-camelcase@^5.0.0, camelcase@^5.3.1:
+camelcase@^5.3.1:
   version "5.3.1"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
+  resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz"
   integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
 
-camelcase@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.0.0.tgz#5259f7c30e35e278f1bdc2a4d91230b37cad981e"
-  integrity sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w==
+camelcase@^6.2.0:
+  version "6.2.0"
+  resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz"
+  integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==
 
 cancel-token@^0.1.1:
   version "0.1.1"
@@ -2378,6 +2699,11 @@
   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"
@@ -2388,14 +2714,13 @@
   resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
   integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
 
-chalk@*, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2:
-  version "2.4.2"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
-  integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
+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 "^3.2.1"
-    escape-string-regexp "^1.0.5"
-    supports-color "^5.3.0"
+    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"
@@ -2408,21 +2733,14 @@
     strip-ansi "^3.0.0"
     supports-color "^2.0.0"
 
-chalk@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4"
-  integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==
+chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2:
+  version "2.4.2"
+  resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz"
+  integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
   dependencies:
-    ansi-styles "^4.1.0"
-    supports-color "^7.1.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==
-  dependencies:
-    ansi-styles "^4.1.0"
-    supports-color "^7.1.0"
+    ansi-styles "^3.2.1"
+    escape-string-regexp "^1.0.5"
+    supports-color "^5.3.0"
 
 chalk@~0.4.0:
   version "0.4.0"
@@ -2435,10 +2753,10 @@
 
 chardet@^0.7.0:
   version "0.7.0"
-  resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
+  resolved "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz"
   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=
@@ -2460,14 +2778,9 @@
     fsevents "^1.0.0"
 
 chownr@^1.0.1:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.2.tgz#a18f1e0b269c8a6a5d3c86eb298beb14c3dd7bf6"
-  integrity sha512-GkfeAQh+QNy3wquu9oIZr6SS5x7wGdSgNQvD10X3r+AZr1Oys22HW8kAmDMvNg2+Dm0TeGaEuO8gFwdBXxwO8A==
-
-chownr@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.1.tgz#54726b8b8fff4df053c42187e801fb4412df1494"
-  integrity sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==
+  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"
@@ -2476,7 +2789,7 @@
 
 ci-info@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46"
+  resolved "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz"
   integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==
 
 class-utils@^0.3.5:
@@ -2490,9 +2803,9 @@
     static-extend "^0.1.1"
 
 clean-css@4.2.x:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.1.tgz#2d411ef76b8569b6d0c84068dabe85b0aa5e5c17"
-  integrity sha512-4ZxI6dy4lrY6FHzfiy1aEOXgu4LIsW2MhwG0VBKdcoGoH/XLFgaHSdLTGr4O8Be6A8r3MOphEiI8Gc1n0ecf3g==
+  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"
 
@@ -2506,10 +2819,10 @@
   resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143"
   integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM=
 
-cli-boxes@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.0.tgz#538ecae8f9c6ca508e3c3c95b453fe93cb4c168d"
-  integrity sha512-gpaBrMAizVEANOpfZp/EEUixTXDyGt7DFzdK5hU+UbWt/J0lB0w20ncZj59Z9a93xHb9u12zF5BS6i9RKbtg4w==
+cli-boxes@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz"
+  integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==
 
 cli-cursor@^1.0.1:
   version "1.0.2"
@@ -2518,40 +2831,47 @@
   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=
-  dependencies:
-    restore-cursor "^2.0.0"
-
 cli-cursor@^3.1.0:
   version "3.1.0"
-  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307"
+  resolved "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz"
   integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==
   dependencies:
     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.npmjs.org/cli-width/-/cli-width-3.0.0.tgz"
+  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"
+  resolved "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz"
   integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=
   dependencies:
     mimic-response "^1.0.0"
@@ -2600,32 +2920,32 @@
 
 color-convert@^1.9.0, color-convert@^1.9.1:
   version "1.9.3"
-  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
+  resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz"
   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.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
+  resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz"
   integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
   dependencies:
     color-name "~1.1.4"
 
-color-name@1.1.3, color-name@^1.0.0:
+color-name@1.1.3:
   version "1.1.3"
-  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
+  resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz"
   integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
 
-color-name@~1.1.4:
+color-name@^1.0.0, color-name@~1.1.4:
   version "1.1.4"
-  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
+  resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"
   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.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"
@@ -2638,10 +2958,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.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"
@@ -2649,9 +2969,9 @@
   integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=
 
 colors@^1.2.1:
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/colors/-/colors-1.3.3.tgz#39e005d546afe01e01f9c4ca8fa50f686a01205d"
-  integrity sha512-mmGt/1pZqYRjMxB1axhTo16/snVZ5krrKkcmMeVKxzECMMXoCgnvTPp10QgHfcbQZw8Dq2jMNG6je4JlWU0gWg==
+  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"
@@ -2661,7 +2981,7 @@
     color "3.0.x"
     text-hex "1.0.x"
 
-combined-stream@^1.0.6, combined-stream@~1.0.6:
+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==
@@ -2700,7 +3020,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==
@@ -2710,10 +3030,10 @@
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
   integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==
 
-comment-parser@^0.7.2:
-  version "0.7.2"
-  resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-0.7.2.tgz#baf6d99b42038678b81096f15b630d18142f4b8a"
-  integrity sha512-4Rjb1FnxtOcv9qsfuaNuVsmmVn4ooVoBHzYfyKteiXwIU84PClyGA5jASoFMwPV93+FPh9spwueXauxFJZkGAg==
+comment-parser@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"
@@ -2730,7 +3050,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==
@@ -2772,7 +3092,7 @@
 
 concat-map@0.0.1:
   version "0.0.1"
-  resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+  resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
   integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
 
 concat-stream@^1.4.7, concat-stream@^1.5.2:
@@ -2801,11 +3121,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"
@@ -2814,7 +3134,7 @@
 
 configstore@^5.0.1:
   version "5.0.1"
-  resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96"
+  resolved "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz"
   integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==
   dependencies:
     dot-prop "^5.2.0"
@@ -2824,14 +3144,9 @@
     write-file-atomic "^3.0.0"
     xdg-basedir "^4.0.0"
 
-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=
-
 contains-path@^0.1.0:
   version "0.1.0"
-  resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a"
+  resolved "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz"
   integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=
 
 content-disposition@0.5.3:
@@ -2846,10 +3161,10 @@
   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.0, convert-source-map@^1.1.1:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20"
-  integrity sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A==
+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"
 
@@ -2858,25 +3173,25 @@
   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:
-  version "2.6.9"
-  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2"
-  integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==
+  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"
@@ -2933,16 +3248,16 @@
     shebang-command "^1.2.0"
     which "^1.2.9"
 
-cross-spawn@^7.0.0:
+cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
   version "7.0.3"
-  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
+  resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz"
   integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
   dependencies:
     path-key "^3.1.0"
     shebang-command "^2.0.0"
     which "^2.0.1"
 
-crypt@~0.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=
@@ -2954,7 +3269,7 @@
 
 crypto-random-string@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
+  resolved "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz"
   integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
 
 css-slam@^2.1.2:
@@ -2985,7 +3300,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==
@@ -3004,22 +3319,22 @@
 
 debug@2.6.9, debug@^2.0.0, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
   version "2.6.9"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+  resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz"
   integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
   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.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.1"
+    ms "2.1.2"
 
-debug@^4.0.1, 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"
 
@@ -3030,9 +3345,16 @@
   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-keys@^1.1.0:
   version "1.1.0"
-  resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9"
+  resolved "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.0.tgz"
   integrity sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=
   dependencies:
     decamelize "^1.1.0"
@@ -3040,7 +3362,7 @@
 
 decamelize@^1.1.0, decamelize@^1.1.2, decamelize@^1.2.0:
   version "1.2.0"
-  resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
+  resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
   integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
 
 decode-uri-component@^0.2.0:
@@ -3050,39 +3372,46 @@
 
 decompress-response@^3.2.0, decompress-response@^3.3.0:
   version "3.3.0"
-  resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3"
+  resolved "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz"
   integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=
   dependencies:
     mimic-response "^1.0.0"
 
-deep-extend@^0.4.0:
-  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"
+  resolved "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz"
   integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
 
-deep-is@~0.1.3:
+deep-is@^0.1.3:
   version "0.1.3"
-  resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
+  resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz"
   integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
 
-deepmerge@4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.0.0.tgz#3e3110ca29205f120d7cb064960a39c3d2087c09"
-  integrity sha512-YZ1rOP5+kHor4hMAH+HRQnBQHg+wvS1un1hAOuIcxcBy0hzcUf6Jg2a1w65kpoOUnurOfZbERwjI1TfZxNjcww==
-
 defer-to-connect@^1.0.1:
   version "1.1.3"
-  resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591"
+  resolved "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz"
   integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==
 
-define-properties@^1.1.2, define-properties@^1.1.3:
+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"
+  resolved "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz"
   integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==
   dependencies:
     object-keys "^1.0.12"
@@ -3126,17 +3455,12 @@
   resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
   integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
 
-delegates@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
-  integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
-
 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.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==
@@ -3170,24 +3494,10 @@
   dependencies:
     repeating "^2.0.0"
 
-detect-libc@^1.0.2:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
-  integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=
-
 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.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"
@@ -3207,6 +3517,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"
@@ -3215,9 +3530,23 @@
     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"
+
+dir-glob@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz"
+  integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==
+  dependencies:
+    path-type "^4.0.0"
+
 doctrine@1.5.0:
   version "1.5.0"
-  resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa"
+  resolved "https://registry.npmjs.org/doctrine/-/doctrine-1.5.0.tgz"
   integrity sha1-N53Ocw9hZvds76TmcHoVmwLFpvo=
   dependencies:
     esutils "^2.0.2"
@@ -3232,17 +3561,18 @@
 
 doctrine@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961"
+  resolved "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz"
   integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==
   dependencies:
     esutils "^2.0.2"
 
-dom-serializer@0:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51"
-  integrity sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==
+dom-serializer@^1.0.1:
+  version "1.3.1"
+  resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.1.tgz"
+  integrity sha512-Pv2ZluG5ife96udGgEDovOOOA5UELkltfJpnIExPrAk1LTvecolUGn6lIaoLh86d83GiB86CjzciMd9BuRB71Q==
   dependencies:
     domelementtype "^2.0.1"
+    domhandler "^4.0.0"
     entities "^2.0.0"
 
 dom-urls@^1.1.0:
@@ -3261,30 +3591,26 @@
     clone "^2.1.0"
     parse5 "^4.0.0"
 
-domelementtype@1, domelementtype@^1.3.1:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f"
-  integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==
+domelementtype@^2.0.1, domelementtype@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz"
+  integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==
 
-domelementtype@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.0.1.tgz#1f8bdfe91f5a78063274e803b4bdcedf6e94f94d"
-  integrity sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ==
-
-domhandler@^2.3.0:
-  version "2.4.2"
-  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803"
-  integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==
+domhandler@^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==
   dependencies:
-    domelementtype "1"
+    domelementtype "^2.2.0"
 
-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==
+domutils@^2.5.2:
+  version "2.5.2"
+  resolved "https://registry.npmjs.org/domutils/-/domutils-2.5.2.tgz"
+  integrity sha512-MHTthCb1zj8f1GVfRpeZUbohQf/HdBos0oX5gZcQFepOZPLLRyj6Wn7XS7EMnY7CVpwv8863u2vyE83Hfu28HQ==
   dependencies:
-    dom-serializer "0"
-    domelementtype "1"
+    dom-serializer "^1.0.1"
+    domelementtype "^2.2.0"
+    domhandler "^4.1.0"
 
 dot-prop@^3.0.0:
   version "3.0.0"
@@ -3293,20 +3619,29 @@
   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"
 
 dot-prop@^5.2.0:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.2.0.tgz#c34ecc29556dc45f1f4c22697b6f4904e0cc4fcb"
-  integrity sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A==
+  version "5.3.0"
+  resolved "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz"
+  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"
@@ -3316,7 +3651,7 @@
 
 duplexer3@^0.1.4:
   version "0.1.4"
-  resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
+  resolved "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz"
   integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
 
 duplexify@^3.2.0, duplexify@^3.5.0, duplexify@^3.6.0:
@@ -3337,23 +3672,35 @@
     jsbn "~0.1.0"
     safer-buffer "^2.1.0"
 
-editions@^2.1.2, editions@^2.1.3:
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/editions/-/editions-2.1.3.tgz#727ccf3ec2c7b12dcc652c71000f16c4824d6f7d"
-  integrity sha512-xDZyVm0A4nLgMNWVVLJvcwMjI80ShiH/27RyLiCnW1L273TcJIA25C4pwJ33AWV01OX6UriP35Xu+lH4S7HWQw==
+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 "^1.1.1"
-    semver "^5.6.0"
+    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:
-  version "2.6.2"
-  resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.6.2.tgz#3a32c63d1cd16d11266cd4703b14fec4e74ab4f6"
-  integrity sha512-PcW2a0tyTuPHz3tWyYqtK6r1fZ3gp+3Sop8Ph+ZYN81Ob5rwmbHEzaqs10N3BEsaGTkh/ooniXK+WwszGlc2+Q==
+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"
@@ -3362,36 +3709,27 @@
 
 emoji-regex@^7.0.1:
   version "7.0.3"
-  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
+  resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz"
   integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==
 
 emoji-regex@^8.0.0:
   version "8.0.0"
-  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
+  resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz"
   integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
 
-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"
+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:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43"
-  integrity sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==
-  dependencies:
-    once "^1.4.0"
-
-end-of-stream@^1.4.1:
+end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1:
   version "1.4.4"
-  resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
+  resolved "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz"
   integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
   dependencies:
     once "^1.4.0"
@@ -3401,103 +3739,102 @@
   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.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.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"
+    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.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"
+    ws "~7.4.2"
 
-entities@^1.1.1:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56"
-  integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==
+enquirer@^2.3.5:
+  version "2.3.6"
+  resolved "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz"
+  integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==
+  dependencies:
+    ansi-colors "^4.1.1"
 
 entities@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4"
-  integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw==
+  version "2.2.0"
+  resolved "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz"
+  integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
 
-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==
-
-errlop@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/errlop/-/errlop-1.1.1.tgz#d9ae4c76c3e64956c5d79e6e035d6343bfd62250"
-  integrity sha512-WX7QjiPHhsny7/PQvrhS5VMizXXKoKCS3udaBp8gjlARdbn+XmK300eKBAAN0hGyRaTCtRpOaxK+xFVPUJ3zkw==
-  dependencies:
-    editions "^2.1.2"
+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:
   version "1.3.2"
-  resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
+  resolved "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz"
   integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==
   dependencies:
     is-arrayish "^0.2.1"
 
 error@^7.0.2:
-  version "7.0.2"
-  resolved "https://registry.yarnpkg.com/error/-/error-7.0.2.tgz#a5f75fff4d9926126ddac0ea5dc38e689153cb02"
-  integrity sha1-pfdf/02ZJhJt2sDqXcOOaJFTywI=
+  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"
-    xtend "~4.0.0"
 
-es-abstract@^1.17.0, es-abstract@^1.17.0-next.1:
-  version "1.17.5"
-  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.5.tgz#d8c9d1d66c8981fb9200e2251d799eee92774ae9"
-  integrity sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==
+es-abstract@^1.18.0-next.1, 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==
   dependencies:
+    call-bind "^1.0.2"
     es-to-primitive "^1.2.1"
     function-bind "^1.1.1"
+    get-intrinsic "^1.1.1"
     has "^1.0.3"
-    has-symbols "^1.0.1"
-    is-callable "^1.1.5"
-    is-regex "^1.0.5"
-    object-inspect "^1.7.0"
+    has-symbols "^1.0.2"
+    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"
     object-keys "^1.1.1"
-    object.assign "^4.1.0"
-    string.prototype.trimleft "^2.1.1"
-    string.prototype.trimright "^2.1.1"
+    object.assign "^4.1.2"
+    string.prototype.trimend "^1.0.4"
+    string.prototype.trimstart "^1.0.4"
+    unbox-primitive "^1.0.0"
 
 es-to-primitive@^1.2.1:
   version "1.2.1"
-  resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a"
+  resolved "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz"
   integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==
   dependencies:
     is-callable "^1.1.4"
@@ -3517,13 +3854,18 @@
     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-goat@^2.0.0:
   version "2.1.1"
-  resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675"
+  resolved "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz"
   integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==
 
 escape-html@^1.0.3, escape-html@~1.0.3:
@@ -3533,87 +3875,85 @@
 
 escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.4, escape-string-regexp@^1.0.5:
   version "1.0.5"
-  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
+  resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz"
   integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
 
-eslint-config-google@^0.13.0:
-  version "0.13.0"
-  resolved "https://registry.yarnpkg.com/eslint-config-google/-/eslint-config-google-0.13.0.tgz#e277d16d2cb25c1ffd3fd13fb0035ad7421382fe"
-  integrity sha512-ELgMdOIpn0CFdsQS+FuxO+Ttu4p+aLaXHv9wA9yVnzqlUGV7oN/eRRnJekk7TCur6Cu2FXX0fqfIXRBaM14lpQ==
+eslint-config-google@^0.14.0:
+  version "0.14.0"
+  resolved "https://registry.npmjs.org/eslint-config-google/-/eslint-config-google-0.14.0.tgz"
+  integrity sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==
 
-eslint-config-prettier@^6.10.1:
-  version "6.11.0"
-  resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.11.0.tgz#f6d2238c1290d01c859a8b5c1f7d352a0b0da8b1"
-  integrity sha512-oB8cpLWSAjOVFEJhhyMZh6NOEOtBVziaqdDQ86+qhDHFbZXoRTM7pNSvFRfW/W/L/LrQ38C99J5CGuRBBzBsdA==
-  dependencies:
-    get-stdin "^6.0.0"
+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"
+  integrity sha512-rV4Qu0C3nfJKPOAhFujFxB7RMP+URFyQqqOZW9DMRD7ZDTFyjaIlETU3xzHELt++4ugC0+Jm084HQYkkJe+Ivg==
 
-eslint-import-resolver-node@^0.3.2:
-  version "0.3.3"
-  resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.3.tgz#dbaa52b6b2816b50bc6711af75422de808e98404"
-  integrity sha512-b8crLDo0M5RSe5YG8Pu2DYBj71tSB6OvXkfzwbJU2w7y8P4/yo0MyF8jU26IEuEuHF2K5/gcAJE3LhQGqBBbVg==
+eslint-import-resolver-node@^0.3.4:
+  version "0.3.4"
+  resolved "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz"
+  integrity sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA==
   dependencies:
     debug "^2.6.9"
     resolve "^1.13.1"
 
-eslint-module-utils@^2.4.1:
-  version "2.5.2"
-  resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.5.2.tgz#7878f7504824e1b857dd2505b59a8e5eda26a708"
-  integrity sha512-LGScZ/JSlqGKiT8OC+cYRxseMjyqt6QO54nl281CK93unD89ijSeRV6An8Ci/2nvWVKe8K/Tqdm75RQoIOCr+Q==
+eslint-module-utils@^2.6.0:
+  version "2.6.0"
+  resolved "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.6.0.tgz"
+  integrity sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA==
   dependencies:
     debug "^2.6.9"
     pkg-dir "^2.0.0"
 
 eslint-plugin-es@^3.0.0:
   version "3.0.1"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz#75a7cdfdccddc0589934aeeb384175f221c57893"
+  resolved "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz"
   integrity sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==
   dependencies:
     eslint-utils "^2.0.0"
     regexpp "^3.0.0"
 
-eslint-plugin-html@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-html/-/eslint-plugin-html-6.0.0.tgz#28e5c3e71e6f612e07e73d7c215e469766628c13"
-  integrity sha512-PQcGippOHS+HTbQCStmH5MY1BF2MaU8qW/+Mvo/8xTa/ioeMXdSP+IiaBw2+nh0KEMfYQKuTz1Zo+vHynjwhbg==
+eslint-plugin-html@^6.1.2:
+  version "6.1.2"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-html/-/eslint-plugin-html-6.1.2.tgz#fa26e4804428956c80e963b6499c192061c2daf3"
+  integrity sha512-bhBIRyZFqI4EoF12lGDHAmgfff8eLXx6R52/K3ESQhsxzCzIE6hdebS7Py651f7U3RBotqroUnC3L29bR7qJWQ==
   dependencies:
-    htmlparser2 "^3.10.1"
+    htmlparser2 "^6.0.1"
 
-eslint-plugin-import@^2.20.1:
-  version "2.20.1"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.20.1.tgz#802423196dcb11d9ce8435a5fc02a6d3b46939b3"
-  integrity sha512-qQHgFOTjguR+LnYRoToeZWT62XM55MBVXObHM6SKFd1VzDcX/vqT1kAz8ssqigh5eMj8qXcRoXXGZpPP6RfdCw==
+eslint-plugin-import@^2.22.1:
+  version "2.22.1"
+  resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz"
+  integrity sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw==
   dependencies:
-    array-includes "^3.0.3"
-    array.prototype.flat "^1.2.1"
+    array-includes "^3.1.1"
+    array.prototype.flat "^1.2.3"
     contains-path "^0.1.0"
     debug "^2.6.9"
     doctrine "1.5.0"
-    eslint-import-resolver-node "^0.3.2"
-    eslint-module-utils "^2.4.1"
+    eslint-import-resolver-node "^0.3.4"
+    eslint-module-utils "^2.6.0"
     has "^1.0.3"
     minimatch "^3.0.4"
-    object.values "^1.1.0"
+    object.values "^1.1.1"
     read-pkg-up "^2.0.0"
-    resolve "^1.12.0"
+    resolve "^1.17.0"
+    tsconfig-paths "^3.9.0"
 
-eslint-plugin-jsdoc@^19.2.0:
-  version "19.2.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-19.2.0.tgz#f522b970878ae402b28ce62187305b33dfe2c834"
-  integrity sha512-QdNifBFLXCDGdy+26RXxcrqzEZarFWNybCZQVqJQYEYPlxd6lm+LPkrs6mCOhaGc2wqC6zqpedBQFX8nQJuKSw==
+eslint-plugin-jsdoc@^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==
   dependencies:
-    comment-parser "^0.7.2"
-    debug "^4.1.1"
-    jsdoctypeparser "^6.1.0"
-    lodash "^4.17.15"
-    object.entries-ponyfill "^1.0.1"
-    regextras "^0.7.0"
-    semver "^6.3.0"
-    spdx-expression-parse "^3.0.0"
+    comment-parser "1.1.2"
+    debug "^4.3.1"
+    jsdoctypeparser "^9.0.0"
+    lodash "^4.17.20"
+    regextras "^0.7.1"
+    semver "^7.3.4"
+    spdx-expression-parse "^3.0.1"
 
 eslint-plugin-node@^11.1.0:
   version "11.1.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz#c95544416ee4ada26740a30474eefc5402dc671d"
+  resolved "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz"
   integrity sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==
   dependencies:
     eslint-plugin-es "^3.0.0"
@@ -3623,87 +3963,128 @@
     resolve "^1.10.1"
     semver "^6.1.0"
 
-eslint-plugin-prettier@^3.1.2:
-  version "3.1.4"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.4.tgz#168ab43154e2ea57db992a2cd097c828171f75c2"
-  integrity sha512-jZDa8z76klRqo+TdGDTFJSavwbnWK2ZpqGKNZ+VvweMW516pDUMmQ2koXvxEE4JhzNvTv+radye/bWGBmA6jmg==
+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==
   dependencies:
     prettier-linter-helpers "^1.0.0"
 
-eslint-plugin-prettier@^3.1.3:
-  version "3.1.3"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.3.tgz#ae116a0fc0e598fdae48743a4430903de5b4e6ca"
-  integrity sha512-+HG5jmu/dN3ZV3T6eCD7a4BlAySdN7mLIbJYo0z1cFQuI+r2DiTJEFeF68ots93PsnrMxbzIZ2S/ieX+mkrBeQ==
+eslint-plugin-prettier@^3.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-scope@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.0.0.tgz#e87c8887c73e8d1ec84f1ca591645c358bfc8fb9"
-  integrity sha512-oYrhJW7S0bxAFDvWqzvMPRm6pcgcnWc4QnofCAqRTRfQC0JcwenzGglTtsLyIuuWFfkqDG9vz67cnttSd53djw==
+eslint-scope@^5.0.0, eslint-scope@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz"
+  integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==
   dependencies:
-    esrecurse "^4.1.0"
+    esrecurse "^4.3.0"
     estraverse "^4.1.1"
 
-eslint-utils@^1.4.3:
-  version "1.4.3"
-  resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.3.tgz#74fec7c54d0776b6f67e0251040b5806564e981f"
-  integrity sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==
-  dependencies:
-    eslint-visitor-keys "^1.1.0"
-
-eslint-utils@^2.0.0:
+eslint-utils@^2.0.0, eslint-utils@^2.1.0:
   version "2.1.0"
-  resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27"
+  resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz"
   integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==
   dependencies:
     eslint-visitor-keys "^1.1.0"
 
-eslint-visitor-keys@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2"
-  integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==
+eslint-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"
+  integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==
 
-eslint@^6.6.0, eslint@^6.8.0:
-  version "6.8.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.8.0.tgz#62262d6729739f9275723824302fb227c8c93ffb"
-  integrity sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig==
+eslint-visitor-keys@^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==
+
+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==
   dependencies:
-    "@babel/code-frame" "^7.0.0"
+    "@babel/code-frame" "7.12.11"
+    "@eslint/eslintrc" "^0.4.0"
     ajv "^6.10.0"
-    chalk "^2.1.0"
-    cross-spawn "^6.0.5"
+    chalk "^4.0.0"
+    cross-spawn "^7.0.2"
     debug "^4.0.1"
     doctrine "^3.0.0"
-    eslint-scope "^5.0.0"
-    eslint-utils "^1.4.3"
-    eslint-visitor-keys "^1.1.0"
-    espree "^6.1.2"
-    esquery "^1.0.1"
+    enquirer "^2.3.5"
+    eslint-scope "^5.1.1"
+    eslint-utils "^2.1.0"
+    eslint-visitor-keys "^2.0.0"
+    espree "^7.3.1"
+    esquery "^1.4.0"
     esutils "^2.0.2"
-    file-entry-cache "^5.0.1"
+    file-entry-cache "^6.0.1"
     functional-red-black-tree "^1.0.1"
     glob-parent "^5.0.0"
-    globals "^12.1.0"
+    globals "^13.6.0"
     ignore "^4.0.6"
     import-fresh "^3.0.0"
     imurmurhash "^0.1.4"
-    inquirer "^7.0.0"
     is-glob "^4.0.0"
     js-yaml "^3.13.1"
     json-stable-stringify-without-jsonify "^1.0.1"
-    levn "^0.3.0"
-    lodash "^4.17.14"
+    levn "^0.4.1"
+    lodash "^4.17.21"
     minimatch "^3.0.4"
-    mkdirp "^0.5.1"
     natural-compare "^1.4.0"
-    optionator "^0.8.3"
+    optionator "^0.9.1"
     progress "^2.0.0"
-    regexpp "^2.0.1"
-    semver "^6.1.2"
-    strip-ansi "^5.2.0"
-    strip-json-comments "^3.0.1"
-    table "^5.2.3"
+    regexpp "^3.1.0"
+    semver "^7.2.1"
+    strip-ansi "^6.0.0"
+    strip-json-comments "^3.1.0"
+    table "^6.0.4"
+    text-table "^0.2.0"
+    v8-compile-cache "^2.0.3"
+
+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"
 
@@ -3715,43 +4096,48 @@
     acorn "^5.5.0"
     acorn-jsx "^3.0.0"
 
-espree@^6.1.2:
-  version "6.1.2"
-  resolved "https://registry.yarnpkg.com/espree/-/espree-6.1.2.tgz#6c272650932b4f91c3714e5e7b5f5e2ecf47262d"
-  integrity sha512-2iUPuuPP+yW1PZaMSDM9eyVf8D5P0Hi8h83YtZ5bPc/zHYjII5khoixIUTMO794NOY8F/ThF1Bo8ncZILarUTA==
+espree@^7.3.0, espree@^7.3.1:
+  version "7.3.1"
+  resolved "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz"
+  integrity sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==
   dependencies:
-    acorn "^7.1.0"
-    acorn-jsx "^5.1.0"
-    eslint-visitor-keys "^1.1.0"
+    acorn "^7.4.0"
+    acorn-jsx "^5.3.1"
+    eslint-visitor-keys "^1.3.0"
 
 esprima@^4.0.0:
   version "4.0.1"
-  resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
+  resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz"
   integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
 
-esquery@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708"
-  integrity sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==
+esquery@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz"
+  integrity sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==
   dependencies:
-    estraverse "^4.0.0"
+    estraverse "^5.1.0"
 
-esrecurse@^4.1.0:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.1.tgz#007a3b9fdbc2b3bb87e4879ea19c92fdbd3942cf"
-  integrity sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==
-  dependencies:
-    estraverse "^4.1.0"
-
-estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1:
+esrecurse@^4.3.0:
   version "4.3.0"
-  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
+  resolved "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz"
+  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"
   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"
+  integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==
+
 esutils@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b"
-  integrity sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=
+  version "2.0.3"
+  resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz"
+  integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
 
 etag@~1.8.1:
   version "1.8.1"
@@ -3759,9 +4145,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"
@@ -3790,9 +4176,9 @@
     strip-eof "^1.0.0"
 
 execa@^4.0.0:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/execa/-/execa-4.0.2.tgz#ad87fb7b2d9d564f70d2b62d511bee41d5cbb240"
-  integrity sha512-QI2zLa6CjGWdiQsmSkZoGtDx2N+cQIGb3yNolGTdjSQzydzLgYYf8LRuagp7S7fPimjcrzUDSUFd/MgzELMi4Q==
+  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"
@@ -3804,6 +4190,21 @@
     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==
+  dependencies:
+    cross-spawn "^7.0.3"
+    get-stream "^6.0.0"
+    human-signals "^2.1.0"
+    is-stream "^2.0.0"
+    merge-stream "^2.0.0"
+    npm-run-path "^4.0.1"
+    onetime "^5.1.2"
+    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"
@@ -3893,16 +4294,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"
@@ -3934,7 +4325,7 @@
 
 external-editor@^3.0.3:
   version "3.1.0"
-  resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495"
+  resolved "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz"
   integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==
   dependencies:
     chardet "^0.7.0"
@@ -3962,22 +4353,27 @@
     snapdragon "^0.8.1"
     to-regex "^3.0.1"
 
-extsprintf@1.3.0, extsprintf@^1.2.0:
+extsprintf@1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
   integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=
 
-fast-deep-equal@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"
-  integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=
+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:
+  version "3.1.3"
+  resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
+  integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
 
 fast-diff@^1.1.2:
   version "1.2.0"
-  resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
+  resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz"
   integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
 
-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==
@@ -3989,20 +4385,39 @@
     merge2 "^1.2.3"
     micromatch "^3.1.10"
 
-fast-json-stable-stringify@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2"
-  integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I=
+fast-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==
+  dependencies:
+    "@nodelib/fs.stat" "^2.0.2"
+    "@nodelib/fs.walk" "^1.2.3"
+    glob-parent "^5.1.0"
+    merge2 "^1.3.0"
+    micromatch "^4.0.2"
+    picomatch "^2.2.1"
 
-fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6:
+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"
+  integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
+
+fast-levenshtein@^2.0.6:
   version "2.0.6"
-  resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
+  resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz"
   integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
 
 fast-safe-stringify@^2.0.4:
-  version "2.0.6"
-  resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.6.tgz#04b26106cc56681f51a044cfc0d76cf0008ac2c2"
-  integrity sha512-q8BZ89jjc+mz08rSxROs8VsrBBcn1SIw1kq9NjolL509tkABRk9io01RAjSaEv1Xb2uFLt8VtRiZbGp5H8iDtg==
+  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==
+  dependencies:
+    reusify "^1.0.4"
 
 fd-slicer@~1.1.0:
   version "1.1.0"
@@ -4016,6 +4431,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"
@@ -4024,26 +4444,31 @@
     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=
-  dependencies:
-    escape-string-regexp "^1.0.5"
-
 figures@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/figures/-/figures-3.1.0.tgz#4b198dd07d8d71530642864af2d45dd9e459c4ec"
-  integrity sha512-ravh8VRXqHuMvZt/d8GblBeqDMkdJMBdv/2KntFH+ra5MXkO7nxNKpzQ3n6QD/2da1kH0aWmNISdvhM7gl2gVg==
+  version "3.2.0"
+  resolved "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz"
+  integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==
   dependencies:
     escape-string-regexp "^1.0.5"
 
-file-entry-cache@^5.0.1:
-  version "5.0.1"
-  resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-5.0.1.tgz#ca0f6efa6dd3d561333fb14515065c2fafdf439c"
-  integrity sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==
+file-entry-cache@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz"
+  integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==
   dependencies:
-    flat-cache "^2.0.1"
+    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"
@@ -4071,6 +4496,13 @@
     repeat-string "^1.6.1"
     to-regex-range "^2.1.0"
 
+fill-range@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz"
+  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"
@@ -4113,7 +4545,7 @@
 
 find-up@^2.0.0, find-up@^2.1.0:
   version "2.1.0"
-  resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7"
+  resolved "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz"
   integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c=
   dependencies:
     locate-path "^2.0.0"
@@ -4127,7 +4559,7 @@
 
 find-up@^4.1.0:
   version "4.1.0"
-  resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
+  resolved "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz"
   integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
   dependencies:
     locate-path "^5.0.0"
@@ -4165,26 +4597,28 @@
   dependencies:
     readable-stream "^2.0.2"
 
-flat-cache@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0"
-  integrity sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==
+flat-cache@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz"
+  integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==
   dependencies:
-    flatted "^2.0.0"
-    rimraf "2.6.3"
-    write "1.0.3"
+    flatted "^3.1.0"
+    rimraf "^3.0.2"
 
-flatted@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.1.tgz#69e57caa8f0eacbc281d2e2cb458d46fdb449e08"
-  integrity sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg==
+flatted@^3.1.0:
+  version "3.1.1"
+  resolved "https://registry.npmjs.org/flatted/-/flatted-3.1.1.tgz"
+  integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==
 
-follow-redirects@^1.0.0:
-  version "1.9.0"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.9.0.tgz#8d5bcdc65b7108fe1508649c79c12d732dcedb4f"
-  integrity sha512-CRcPzsSIbXyVDl0QI01muNDu69S8trU4jArW9LpOt2WtC6LyUJetcIrmfHsRBx7/Jb6GHJUiuqyYxPooFfNt6A==
-  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.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:
   version "1.0.2"
@@ -4208,6 +4642,15 @@
   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"
@@ -4256,65 +4699,53 @@
   resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add"
   integrity sha1-mC1ok6+RjnLQjeyehnP/K1qNat0=
 
-fs-minipass@^1.2.5:
-  version "1.2.5"
-  resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.5.tgz#06c277218454ec288df77ada54a03b8702aacb9d"
-  integrity sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==
-  dependencies:
-    minipass "^2.2.1"
-
 fs.realpath@^1.0.0:
   version "1.0.0"
-  resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+  resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
   integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
 
 fsevents@^1.0.0:
-  version "1.2.9"
-  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.9.tgz#3f5ed66583ccd6f400b5a00db6f7e861363e388f"
-  integrity sha512-oeyj2H3EjjonWcFjD5NvZNE9Rqe4UW+nQBU2HNeKw0koVLEFIhtyETyAakeAM3de7Z/SW5kcA+fZUait9EApnw==
+  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"
-    node-pre-gyp "^0.12.0"
 
-fsevents@~2.1.2:
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
-  integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==
+fsevents@~2.3.1:
+  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"
+  resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"
   integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
 
 functional-red-black-tree@^1.0.1:
   version "1.0.1"
-  resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
+  resolved "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz"
   integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
 
-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=
+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, get-intrinsic@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz"
+  integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==
   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"
+    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-stdin@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b"
-  integrity sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==
-
 get-stream@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
@@ -4322,18 +4753,23 @@
 
 get-stream@^4.0.0, get-stream@^4.1.0:
   version "4.1.0"
-  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5"
+  resolved "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz"
   integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==
   dependencies:
     pump "^3.0.0"
 
 get-stream@^5.0.0, get-stream@^5.1.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.1.0.tgz#01203cdc92597f9b909067c3e656cc1f4d3c4dc9"
-  integrity sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==
+  version "5.2.0"
+  resolved "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz"
+  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==
+
 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"
@@ -4346,6 +4782,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"
@@ -4354,6 +4798,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"
@@ -4384,10 +4835,10 @@
     is-glob "^3.1.0"
     path-dirname "^1.0.0"
 
-glob-parent@^5.0.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.0.tgz#5f4c1d1e748d30cd73ad2944b3577a81b081e8c2"
-  integrity sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw==
+glob-parent@^5.0.0, glob-parent@^5.1.0:
+  version "5.1.2"
+  resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
+  integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
   dependencies:
     is-glob "^4.0.1"
 
@@ -4432,21 +4883,9 @@
     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:
-  version "7.1.4"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255"
-  integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==
-  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"
-
-glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
+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"
+  resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz"
   integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
   dependencies:
     fs.realpath "^1.0.0"
@@ -4463,12 +4902,12 @@
   dependencies:
     ini "^1.3.4"
 
-global-dirs@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-2.0.1.tgz#acdf3bb6685bcd55cb35e8a052266569e9469201"
-  integrity sha512-5HqUqdhkEovj2Of/ms3IeS/EekcO54ytHRLV4PEY2rhRwrHXLQjeVEES0Lhka0xwNDtGYn58wyC4s5+MHsOO6A==
+global-dirs@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.0.tgz"
+  integrity sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA==
   dependencies:
-    ini "^1.3.5"
+    ini "2.0.0"
 
 global-modules@^0.2.3:
   version "0.2.3"
@@ -4514,17 +4953,36 @@
   integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
 
 globals@^12.1.0:
-  version "12.3.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-12.3.0.tgz#1e564ee5c4dded2ab098b0f88f24702a3c56be13"
-  integrity sha512-wAfjdLgFsPZsklLJvOBUBmzYE8/CwhEqSBEMRXA3qxIiNtyqvjYurAtIfDh6chlEPUfmTY3MnZh5Hfh4q0UlIw==
+  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==
+  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==
+  dependencies:
+    array-union "^2.1.0"
+    dir-glob "^3.0.1"
+    fast-glob "^3.1.1"
+    ignore "^5.1.4"
+    merge2 "^1.3.0"
+    slash "^3.0.0"
+
 globby@^4.0.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/globby/-/globby-4.1.0.tgz#080f54549ec1b82a6c60e631fc82e1211dbe95f8"
@@ -4561,11 +5019,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.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"
@@ -4587,7 +5076,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=
@@ -4626,7 +5115,7 @@
 
 got@^9.6.0:
   version "9.6.0"
-  resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85"
+  resolved "https://registry.npmjs.org/got/-/got-9.6.0.tgz"
   integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==
   dependencies:
     "@sindresorhus/is" "^0.14.0"
@@ -4641,42 +5130,45 @@
     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:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.0.tgz#8d8fdc73977cb04104721cb53666c1ca64cd328b"
-  integrity sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg==
+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==
 
-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==
-
-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"
 
-gts@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/gts/-/gts-2.0.2.tgz#b8b28de99361b5c5c24db30a375a0f546bbc04a4"
-  integrity sha512-SLytzl2IqKXf6kGULwr07XQ9lVsvjrzFD3OAA7DEfIQYuD+lKBPt/cZ/RYGxaWerY4PTfmnXT7KdxEr9Ec8uHQ==
+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:
-    "@typescript-eslint/eslint-plugin" "2.31.0"
-    "@typescript-eslint/parser" "2.31.0"
-    chalk "^4.0.0"
-    eslint "^6.8.0"
-    eslint-config-prettier "^6.10.1"
+    lodash "^4.17.15"
+
+gts@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/gts/-/gts-3.1.0.tgz#b27ce914191ed6ad34781968d0c77e0ed3042388"
+  integrity sha512-Pbj3ob1VR1IRlEVEBNtKoQ1wHOa8cZz62KEojK8Fn/qeS2ClWI4gLNfhek3lD68aZSmUEg8TFb6AHXIwUMgyqQ==
+  dependencies:
+    "@typescript-eslint/eslint-plugin" "^4.2.0"
+    "@typescript-eslint/parser" "^4.2.0"
+    chalk "^4.1.0"
+    eslint "^7.10.0"
+    eslint-config-prettier "^7.0.0"
     eslint-plugin-node "^11.1.0"
-    eslint-plugin-prettier "^3.1.2"
-    execa "^4.0.0"
-    inquirer "^7.1.0"
-    meow "^7.0.0"
+    eslint-plugin-prettier "^3.1.4"
+    execa "^5.0.0"
+    inquirer "^7.3.3"
+    json5 "^2.1.3"
+    meow "^9.0.0"
     ncp "^2.0.0"
-    prettier "^2.0.4"
+    prettier "^2.1.2"
     rimraf "^3.0.2"
-    update-notifier "^4.1.0"
+    update-notifier "^5.0.0"
     write-file-atomic "^3.0.3"
 
 gulp-if@^2.0.2:
@@ -4689,9 +5181,9 @@
     through2 "^2.0.1"
 
 gulp-match@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/gulp-match/-/gulp-match-1.0.3.tgz#91c7c0d7f29becd6606d57d80a7f8776a87aba8e"
-  integrity sha1-kcfA1/Kb7NZgbVfYCn+Hdqh6uo4=
+  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"
 
@@ -4707,9 +5199,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"
@@ -4728,17 +5220,17 @@
   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"
 
 hard-rejection@^2.1.0:
   version "2.1.0"
-  resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883"
+  resolved "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz"
   integrity sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==
 
 has-ansi@^2.0.0:
@@ -4748,6 +5240,11 @@
   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"
+  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"
@@ -4767,12 +5264,12 @@
 
 has-flag@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
+  resolved "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz"
   integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
 
 has-flag@^4.0.0:
   version "4.0.0"
-  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
+  resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz"
   integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
 
 has-symbol-support-x@^1.4.1:
@@ -4780,15 +5277,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.0"
-  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44"
-  integrity sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=
-
-has-symbols@^1.0.1:
-  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, has-symbols@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz"
+  integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
 
 has-to-string-tag-x@^1.2.0:
   version "1.4.1"
@@ -4797,11 +5289,6 @@
   dependencies:
     has-symbol-support-x "^1.4.1"
 
-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=
-
 has-value@^0.3.1:
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f"
@@ -4835,12 +5322,12 @@
 
 has-yarn@^2.1.0:
   version "2.1.0"
-  resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77"
+  resolved "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz"
   integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==
 
 has@^1.0.3:
   version "1.0.3"
-  resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
+  resolved "https://registry.npmjs.org/has/-/has-1.0.3.tgz"
   integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
   dependencies:
     function-bind "^1.1.1"
@@ -4858,9 +5345,16 @@
     parse-passwd "^1.0.0"
 
 hosted-git-info@^2.1.4:
-  version "2.7.1"
-  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047"
-  integrity sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==
+  version "2.8.9"
+  resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz"
+  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"
+  integrity sha512-c9OGXbZ3guC/xOlCg1Ci/VgWlwsqDv1yMQL1CWqXDL0hDjXuNcq0zuR4xqPSuasI3kqFDhqSyTjREz5gzq0fXg==
+  dependencies:
+    lru-cache "^6.0.0"
 
 hpack.js@^2.1.6:
   version "2.1.6"
@@ -4885,21 +5379,19 @@
     relateurl "0.2.x"
     uglify-js "3.4.x"
 
-htmlparser2@^3.10.1:
-  version "3.10.1"
-  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f"
-  integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==
+htmlparser2@^6.0.1:
+  version "6.1.0"
+  resolved "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz"
+  integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==
   dependencies:
-    domelementtype "^1.3.1"
-    domhandler "^2.3.0"
-    domutils "^1.5.1"
-    entities "^1.1.1"
-    inherits "^2.0.1"
-    readable-stream "^3.1.1"
+    domelementtype "^2.0.1"
+    domhandler "^4.0.0"
+    domutils "^2.5.2"
+    entities "^2.0.0"
 
 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"
+  resolved "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz"
   integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
 
 http-deceiver@^1.2.7:
@@ -4907,7 +5399,7 @@
   resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
   integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=
 
-http-errors@1.7.2, http-errors@~1.7.2:
+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==
@@ -4928,6 +5420,17 @@
     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"
@@ -4939,9 +5442,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"
@@ -4956,6 +5459,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"
@@ -4964,69 +5475,67 @@
     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, iconv-lite@^0.4.4:
+human-signals@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz"
+  integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
+
+iconv-lite@0.4.24, iconv-lite@^0.4.24:
   version "0.4.24"
-  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
+  resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz"
   integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
   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==
-
-ignore-walk@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8"
-  integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==
-  dependencies:
-    minimatch "^3.0.4"
+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.6:
+ignore@^4.0.3, ignore@^4.0.6:
   version "4.0.6"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
+  resolved "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz"
   integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
 
-ignore@^5.1.1:
+ignore@^5.1.1, ignore@^5.1.4:
   version "5.1.8"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57"
+  resolved "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz"
   integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
 
-import-fresh@^3.0.0:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66"
-  integrity sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==
+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"
+  integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==
   dependencies:
     parent-module "^1.0.0"
     resolve-from "^4.0.0"
 
 import-lazy@^2.1.0:
   version "2.1.0"
-  resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
+  resolved "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz"
   integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=
 
 imurmurhash@^0.1.4:
   version "0.1.4"
-  resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
+  resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz"
   integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
 
 indent-string@^2.1.0:
@@ -5038,7 +5547,7 @@
 
 indent-string@^4.0.0:
   version "4.0.0"
-  resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251"
+  resolved "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz"
   integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
 
 indent@0.0.2:
@@ -5053,15 +5562,15 @@
 
 inflight@^1.0.4:
   version "1.0.6"
-  resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+  resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz"
   integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
   dependencies:
     once "^1.3.0"
     wrappy "1"
 
-inherits@2, 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"
+  resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
 
 inherits@2.0.3:
@@ -5069,10 +5578,15 @@
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
   integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
 
-ini@^1.3.4, ini@^1.3.5, ini@~1.3.0:
-  version "1.3.5"
-  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
-  integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
+ini@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz"
+  integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==
+
+ini@^1.3.4, ini@~1.3.0:
+  version "1.3.8"
+  resolved "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz"
+  integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
 
 inquirer@^1.0.2:
   version "1.2.3"
@@ -5094,67 +5608,29 @@
     strip-ansi "^3.0.0"
     through "^2.3.6"
 
-inquirer@^6.0.0:
-  version "6.4.1"
-  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.4.1.tgz#7bd9e5ab0567cd23b41b0180b68e0cfa82fc3c0b"
-  integrity sha512-/Jw+qPZx4EDYsaT6uz7F4GJRNFMRdKNeUZw3ZnKV8lyuUgz/YWRCSUAJMZSVhSq4Ec0R2oYnyi6b3d4JXcL5Nw==
-  dependencies:
-    ansi-escapes "^3.2.0"
-    chalk "^2.4.2"
-    cli-cursor "^2.1.0"
-    cli-width "^2.0.0"
-    external-editor "^3.0.3"
-    figures "^2.0.0"
-    lodash "^4.17.11"
-    mute-stream "0.0.7"
-    run-async "^2.2.0"
-    rxjs "^6.4.0"
-    string-width "^2.1.0"
-    strip-ansi "^5.1.0"
-    through "^2.3.6"
-
-inquirer@^7.0.0:
-  version "7.0.3"
-  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.0.3.tgz#f9b4cd2dff58b9f73e8d43759436ace15bed4567"
-  integrity sha512-+OiOVeVydu4hnCGLCSX+wedovR/Yzskv9BFqUNNKq9uU2qg7LCcCo3R86S2E7WLo0y/x2pnEZfZe1CoYnORUAw==
+inquirer@^7.1.0, inquirer@^7.3.3:
+  version "7.3.3"
+  resolved "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz"
+  integrity sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==
   dependencies:
     ansi-escapes "^4.2.1"
-    chalk "^2.4.2"
+    chalk "^4.1.0"
     cli-cursor "^3.1.0"
-    cli-width "^2.0.0"
+    cli-width "^3.0.0"
     external-editor "^3.0.3"
     figures "^3.0.0"
-    lodash "^4.17.15"
-    mute-stream "0.0.8"
-    run-async "^2.2.0"
-    rxjs "^6.5.3"
-    string-width "^4.1.0"
-    strip-ansi "^5.1.0"
-    through "^2.3.6"
-
-inquirer@^7.1.0:
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.2.0.tgz#63ce99d823090de7eb420e4bb05e6f3449aa389a"
-  integrity sha512-E0c4rPwr9ByePfNlTIB8z51kK1s2n6jrHuJeEHENl/sbq2G/S1auvibgEwNR4uSyiU+PiYHqSwsgGiXjG8p5ZQ==
-  dependencies:
-    ansi-escapes "^4.2.1"
-    chalk "^3.0.0"
-    cli-cursor "^3.1.0"
-    cli-width "^2.0.0"
-    external-editor "^3.0.3"
-    figures "^3.0.0"
-    lodash "^4.17.15"
+    lodash "^4.17.19"
     mute-stream "0.0.8"
     run-async "^2.4.0"
-    rxjs "^6.5.3"
+    rxjs "^6.6.0"
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
     through "^2.3.6"
 
 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"
@@ -5168,10 +5644,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"
@@ -5189,7 +5665,7 @@
 
 is-arrayish@^0.2.1:
   version "0.2.1"
-  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
+  resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz"
   integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
 
 is-arrayish@^0.3.1:
@@ -5197,6 +5673,11 @@
   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"
@@ -5204,15 +5685,22 @@
   dependencies:
     binary-extensions "^1.0.0"
 
-is-buffer@^1.1.5, is-buffer@~1.1.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==
+  dependencies:
+    call-bind "^1.0.0"
+
+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==
 
-is-callable@^1.1.4, is-callable@^1.1.5:
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab"
-  integrity sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==
+is-callable@^1.1.4, is-callable@^1.2.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"
@@ -5223,11 +5711,18 @@
 
 is-ci@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c"
+  resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz"
   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==
+  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"
@@ -5244,7 +5739,7 @@
 
 is-date-object@^1.0.1:
   version "1.0.2"
-  resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e"
+  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:
@@ -5301,15 +5796,13 @@
 
 is-extglob@^2.1.0, is-extglob@^2.1.1:
   version "2.1.1"
-  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+  resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz"
   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"
@@ -5320,12 +5813,12 @@
 
 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"
+  resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz"
   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"
+  resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz"
   integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
 
 is-glob@^2.0.0, is-glob@^2.0.1:
@@ -5344,7 +5837,7 @@
 
 is-glob@^4.0.0, is-glob@^4.0.1:
   version "4.0.1"
-  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc"
+  resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz"
   integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
   dependencies:
     is-extglob "^2.1.1"
@@ -5362,23 +5855,33 @@
     global-dirs "^0.1.0"
     is-path-inside "^1.0.0"
 
-is-installed-globally@^0.3.1:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141"
-  integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g==
+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"
+  integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==
   dependencies:
-    global-dirs "^2.0.1"
-    is-path-inside "^3.0.1"
+    global-dirs "^3.0.0"
+    is-path-inside "^3.0.2"
+
+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"
+  integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==
 
 is-npm@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4"
   integrity sha1-8vtjpl5JBbQGyGBydloaTceTufQ=
 
-is-npm@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-4.0.0.tgz#c90dd8380696df87a7a6d823c20d0b12bbe3c84d"
-  integrity sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig==
+is-npm@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.npmjs.org/is-npm/-/is-npm-5.0.0.tgz"
+  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"
@@ -5399,6 +5902,11 @@
   resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff"
   integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==
 
+is-number@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz"
+  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"
@@ -5406,13 +5914,13 @@
 
 is-obj@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982"
+  resolved "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz"
   integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==
 
 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"
@@ -5433,14 +5941,14 @@
   dependencies:
     path-is-inside "^1.0.1"
 
-is-path-inside@^3.0.1:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.2.tgz#f5220fc82a3e233757291dddc9c5877f2a1f3017"
-  integrity sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg==
+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"
+  integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==
 
 is-plain-obj@^1.0.0, is-plain-obj@^1.1.0:
   version "1.1.0"
-  resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
+  resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz"
   integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4=
 
 is-plain-object@^2.0.3, is-plain-object@^2.0.4:
@@ -5450,12 +5958,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"
@@ -5463,36 +5969,32 @@
   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"
   integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=
 
-is-regex@^1.0.5:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.5.tgz#39d589a358bf18967f726967120b8fc1aed74eae"
-  integrity sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==
+is-regex@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.2.tgz"
+  integrity sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg==
   dependencies:
-    has "^1.0.3"
+    call-bind "^1.0.2"
+    has-symbols "^1.0.1"
 
 is-retry-allowed@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz#11a060568b67339444033d0125a61a20d564fb34"
-  integrity sha1-EaBgVotnM5REAz0BJaYaINVk+zQ=
+  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"
@@ -5508,27 +6010,27 @@
 
 is-stream@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3"
+  resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz"
   integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==
 
 is-string@^1.0.5:
   version "1.0.5"
-  resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6"
+  resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz"
   integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==
 
-is-symbol@^1.0.2:
+is-symbol@^1.0.2, is-symbol@^1.0.3:
   version "1.0.3"
-  resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937"
+  resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz"
   integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==
   dependencies:
     has-symbols "^1.0.1"
 
 is-typedarray@^1.0.0, is-typedarray@~1.0.0:
   version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
+  resolved "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz"
   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=
@@ -5550,7 +6052,7 @@
 
 is-yarn-global@^0.3.0:
   version "0.3.0"
-  resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232"
+  resolved "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz"
   integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==
 
 isarray@0.0.1:
@@ -5560,7 +6062,7 @@
 
 isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0:
   version "1.0.0"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
+  resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz"
   integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
 
 isarray@2.0.1:
@@ -5575,9 +6077,14 @@
   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.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
+  resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz"
   integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
 
 isobject@^2.0.0:
@@ -5592,24 +6099,19 @@
   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:
-  version "2.5.1"
-  resolved "https://registry.yarnpkg.com/istextorbinary/-/istextorbinary-2.5.1.tgz#14a33824cf6b9d5d7743eac1be2bd2c310d0ccbd"
-  integrity sha512-pv/JNPWnfpwGjPx7JrtWTwsWsxkrK3fNzcEVnt92YKEIErps4Fsk49+qzCe9iQF2hjqK8Naqf8P9kzoeCuQI1g==
+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.1.3"
-    textextensions "^2.4.0"
+    editions "^2.2.0"
+    textextensions "^2.5.0"
 
 isurl@^1.0.0-alpha5:
   version "1.0.0"
@@ -5619,9 +6121,19 @@
     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:
   version "4.0.0"
-  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
+  resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz"
   integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
 
 js-tokens@^3.0.2:
@@ -5630,9 +6142,9 @@
   integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls=
 
 js-yaml@^3.13.1:
-  version "3.13.1"
-  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
-  integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==
+  version "3.14.1"
+  resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz"
+  integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==
   dependencies:
     argparse "^1.0.7"
     esprima "^4.0.0"
@@ -5642,10 +6154,10 @@
   resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
   integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
 
-jsdoctypeparser@^6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/jsdoctypeparser/-/jsdoctypeparser-6.1.0.tgz#acfb936c26300d98f1405cb03e20b06748e512a8"
-  integrity sha512-UCQBZ3xCUBv/PLfwKAJhp6jmGOSLFNKzrotXGNgbKhWvz27wPsCsVeP7gIcHPElQw2agBmynAitXqhxR58XAmA==
+jsdoctypeparser@^9.0.0:
+  version "9.0.0"
+  resolved "https://registry.npmjs.org/jsdoctypeparser/-/jsdoctypeparser-9.0.0.tgz"
+  integrity sha512-jrTA2jJIL6/DAEILBEh2/w9QxCuwmvNXIry39Ay/HVfhE3o2yVV0U44blYkqdHA/OKloJEqvJy0xU+GSdE2SIw==
 
 jsesc@^1.3.0:
   version "1.3.0"
@@ -5664,19 +6176,34 @@
 
 json-buffer@3.0.0:
   version "3.0.0"
-  resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898"
+  resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz"
   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"
   integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==
 
+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"
+  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"
+  resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz"
   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"
+  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"
@@ -5684,7 +6211,7 @@
 
 json-stable-stringify-without-jsonify@^1.0.1:
   version "1.0.1"
-  resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
+  resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz"
   integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
 
 json-stringify-safe@~5.0.1:
@@ -5692,17 +6219,29 @@
   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.0"
-  resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.0.tgz#e7a0c62c48285c628d20a10b85c89bb807c32850"
-  integrity sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ==
+json5@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz"
+  integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==
   dependencies:
     minimist "^1.2.0"
 
+json5@^2.1.2, json5@^2.1.3:
+  version "2.2.0"
+  resolved "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz"
+  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.2.4"
-  resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.2.4.tgz#a46bac5d3506a254465bc548876e267c6d0d6464"
-  integrity sha512-lz1nOH69GbsVHeVgEdvyavc/33oymY1AZwtePMiMj4HZPMbP5OIKK3zT9INMWjwua/V4Z4yq7wSlBbSG+g4AEw==
+  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"
@@ -5716,11 +6255,18 @@
 
 keyv@^3.0.0:
   version "3.1.0"
-  resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"
+  resolved "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz"
   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"
@@ -5740,22 +6286,15 @@
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d"
   integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==
 
-kind-of@^6.0.0, kind-of@^6.0.2:
-  version "6.0.2"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051"
-  integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==
-
-kind-of@^6.0.3:
+kind-of@^6.0.0, kind-of@^6.0.2, kind-of@^6.0.3:
   version "6.0.3"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
+  resolved "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz"
   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"
@@ -5771,17 +6310,17 @@
   dependencies:
     package-json "^4.0.0"
 
-latest-version@^5.0.0:
+latest-version@^5.1.0:
   version "5.1.0"
-  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face"
+  resolved "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz"
   integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==
   dependencies:
     package-json "^6.3.0"
 
 launchpad@^0.7.0:
-  version "0.7.4"
-  resolved "https://registry.yarnpkg.com/launchpad/-/launchpad-0.7.4.tgz#08a7a38f48b963e73dc68be84f9f8f974c46c26b"
-  integrity sha512-3KDFHbvXm52CEA4vSQ9kz4vwAxH7SANj2k6SJcE6LCpmMJFOu6wCpZwXb+pq9kSDknl5XInBWeOl40i00P6wTQ==
+  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"
@@ -5792,6 +6331,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"
@@ -5804,17 +6350,17 @@
   dependencies:
     readable-stream "^2.0.5"
 
-levn@^0.3.0, levn@~0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee"
-  integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=
+levn@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz"
+  integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==
   dependencies:
-    prelude-ls "~1.1.2"
-    type-check "~0.3.2"
+    prelude-ls "^1.2.1"
+    type-check "~0.4.0"
 
 lines-and-columns@^1.1.6:
   version "1.1.6"
-  resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
+  resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz"
   integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
 
 load-json-file@^1.0.0:
@@ -5830,7 +6376,7 @@
 
 load-json-file@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8"
+  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"
@@ -5850,7 +6396,7 @@
 
 locate-path@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
+  resolved "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz"
   integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=
   dependencies:
     p-locate "^2.0.0"
@@ -5866,7 +6412,7 @@
 
 locate-path@^5.0.0:
   version "5.0.0"
-  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
+  resolved "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz"
   integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
   dependencies:
     p-locate "^4.1.0"
@@ -5881,6 +6427,11 @@
   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"
+  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"
@@ -5893,7 +6444,7 @@
 
 lodash.flatten@^4.4.0:
   version "4.4.0"
-  resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
+  resolved "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz"
   integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=
 
 lodash.get@^4.4.2:
@@ -5911,6 +6462,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"
@@ -5921,11 +6482,6 @@
   resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
   integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=
 
-lodash.some@^4.6.0:
-  version "4.6.0"
-  resolved "https://registry.yarnpkg.com/lodash.some/-/lodash.some-4.6.0.tgz#1bb9f314ef6b8baded13b549169b2a945eb68e4d"
-  integrity sha1-G7nzFO9ri63tE7VJFpsqlF62jk0=
-
 lodash.sortby@^4.7.0:
   version "4.7.0"
   resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
@@ -5946,6 +6502,11 @@
   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"
+  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"
@@ -5961,15 +6522,10 @@
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
   integrity sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=
 
-lodash@^4.0.0, lodash@^4.16.6, lodash@^4.17.14, lodash@^4.17.15:
-  version "4.17.15"
-  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
-  integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
-
-lodash@^4.11.1, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.3.0:
-  version "4.17.12"
-  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.12.tgz#a712c74fdc31f7ecb20fe44f157d802d208097ef"
-  integrity sha512-+CiwtLnsJhX03p20mwXuvhoebatoh5B3tt+VvYlrPgZC1g36y+RRbkufX95Xa+X4I59aWEacDFYwnJZiyBh9gA==
+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:
+  version "4.17.21"
+  resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
+  integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
 
 log-symbols@^1.0.0, log-symbols@^1.0.1:
   version "1.0.2"
@@ -5996,14 +6552,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"
 
@@ -6014,7 +6570,7 @@
 
 long@^4.0.0:
   version "4.0.0"
-  resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
+  resolved "https://registry.npmjs.org/long/-/long-4.0.0.tgz"
   integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==
 
 loose-envify@^1.0.0:
@@ -6039,12 +6595,12 @@
 
 lowercase-keys@^1.0.0, lowercase-keys@^1.0.1:
   version "1.0.1"
-  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
+  resolved "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz"
   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"
+  resolved "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz"
   integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==
 
 lru-cache@^4.0.1, lru-cache@^4.0.2:
@@ -6055,10 +6611,17 @@
     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"
+  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.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"
@@ -6076,7 +6639,7 @@
 
 make-dir@^3.0.0:
   version "3.1.0"
-  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
+  resolved "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz"
   integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
   dependencies:
     semver "^6.0.0"
@@ -6088,13 +6651,13 @@
 
 map-obj@^1.0.0, map-obj@^1.0.1:
   version "1.0.1"
-  resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d"
+  resolved "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz"
   integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=
 
 map-obj@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.1.0.tgz#b91221b542734b9f14256c0132c897c5d7256fd5"
-  integrity sha512-glc9y00wgtwcDmp7GaE/0b0OnxpNJsVf3ael/An6Fe2Q51LLwN1er6sdomLRzz5h0+yMpiYLhWYF5R7HeqVd4g==
+  version "4.2.1"
+  resolved "https://registry.npmjs.org/map-obj/-/map-obj-4.2.1.tgz"
+  integrity sha512-+WA2/1sPmDj1dlvvJmB5G6JKfY9dpn7EVBUL06+y6PoljPkh+6V1QihwxNkbcGxCRjt2b0F9K0taiCuo7MbdFQ==
 
 map-visit@^1.0.0:
   version "1.0.0"
@@ -6116,13 +6679,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"
@@ -6146,16 +6709,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=
@@ -6171,24 +6768,23 @@
     redent "^1.0.0"
     trim-newlines "^1.0.0"
 
-meow@^7.0.0:
-  version "7.0.1"
-  resolved "https://registry.yarnpkg.com/meow/-/meow-7.0.1.tgz#1ed4a0a50b3844b451369c48362eb0515f04c1dc"
-  integrity sha512-tBKIQqVrAHqwit0vfuFPY3LlzJYkEOFyKa3bPgxzNl6q/RtN8KQ+ALYEASYuFayzSAsjlhXj/JZ10rH85Q6TUw==
+meow@^9.0.0:
+  version "9.0.0"
+  resolved "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz"
+  integrity sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==
   dependencies:
     "@types/minimist" "^1.2.0"
-    arrify "^2.0.1"
-    camelcase "^6.0.0"
     camelcase-keys "^6.2.2"
+    decamelize "^1.2.0"
     decamelize-keys "^1.1.0"
     hard-rejection "^2.1.0"
-    minimist-options "^4.0.2"
-    normalize-package-data "^2.5.0"
+    minimist-options "4.1.0"
+    normalize-package-data "^3.0.0"
     read-pkg-up "^7.0.1"
     redent "^3.0.0"
     trim-newlines "^3.0.0"
-    type-fest "^0.13.1"
-    yargs-parser "^18.1.3"
+    type-fest "^0.18.0"
+    yargs-parser "^20.2.3"
 
 merge-descriptors@1.0.1:
   version "1.0.1"
@@ -6204,13 +6800,13 @@
 
 merge-stream@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
+  resolved "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz"
   integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
 
-merge2@^1.2.3:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.2.3.tgz#7ee99dbd69bb6481689253f018488a1b902b0ed5"
-  integrity sha512-gdUU1Fwj5ep4kplwcmftruWofEFt6lfpkkr3h860CXbAB9c3hGb55EOL2ali0Td5oebvW0E1+3Sr+Ur7XfKpRA==
+merge2@^1.2.3, merge2@^1.3.0:
+  version "1.4.1"
+  resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz"
+  integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
 
 methods@~1.1.2:
   version "1.1.2"
@@ -6255,29 +6851,25 @@
     snapdragon "^0.8.1"
     to-regex "^3.0.2"
 
-mime-db@1.40.0, mime-db@^1.28.0:
-  version "1.40.0"
-  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32"
-  integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==
-
-mime-db@1.43.0, "mime-db@>= 1.43.0 < 2":
-  version "1.43.0"
-  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.43.0.tgz#0a12e0502650e473d735535050e7c8f4eb4fae58"
-  integrity sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==
-
-mime-types@^2.1.12, mime-types@~2.1.19:
-  version "2.1.24"
-  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81"
-  integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==
+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==
   dependencies:
-    mime-db "1.40.0"
+    braces "^3.0.1"
+    picomatch "^2.0.5"
 
-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==
+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.43.0"
+    mime-db "1.47.0"
 
 mime@1.4.1:
   version "1.4.1"
@@ -6290,28 +6882,28 @@
   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==
-
-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==
+  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@^2.1.0:
   version "2.1.0"
-  resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
+  resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz"
   integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
 
 mimic-response@^1.0.0, mimic-response@^1.0.1:
   version "1.0.1"
-  resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
+  resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz"
   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.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
+  resolved "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz"
   integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
 
 minimalistic-assert@^1.0.0:
@@ -6328,44 +6920,29 @@
 
 "minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4:
   version "3.0.4"
-  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
+  resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz"
   integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
   dependencies:
     brace-expansion "^1.1.7"
 
-minimist-options@^4.0.2:
+minimist-options@4.1.0:
   version "4.1.0"
-  resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619"
+  resolved "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz"
   integrity sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==
   dependencies:
     arrify "^1.0.1"
     is-plain-obj "^1.1.0"
     kind-of "^6.0.3"
 
-minimist@0.0.8, minimist@~0.0.1:
-  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=
-
-minipass@^2.2.1, minipass@^2.3.4:
-  version "2.3.5"
-  resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.5.tgz#cacebe492022497f656b0f0f51e2682a9ed2d848"
-  integrity sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==
-  dependencies:
-    safe-buffer "^5.1.2"
-    yallist "^3.0.0"
-
-minizlib@^1.1.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.2.1.tgz#dd27ea6136243c7c880684e8672bb3a45fd9b614"
-  integrity sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==
-  dependencies:
-    minipass "^2.2.1"
+minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5:
+  version "1.2.5"
+  resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz"
+  integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
 
 mixin-deep@^1.2.0:
   version "1.3.2"
@@ -6376,11 +6953,21 @@
     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=
+  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"
@@ -6389,7 +6976,7 @@
 
 ms@2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+  resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
   integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
 
 ms@2.1.1:
@@ -6397,11 +6984,16 @@
   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"
+  resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
   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"
@@ -6426,6 +7018,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"
@@ -6439,14 +7042,9 @@
   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"
+  resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz"
   integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
 
 mz@^2.4.0, mz@^2.6.0:
@@ -6459,9 +7057,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.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"
@@ -6487,23 +7085,14 @@
 
 natural-compare@^1.4.0:
   version "1.4.0"
-  resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
+  resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
   integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
 
 ncp@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
+  resolved "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz"
   integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=
 
-needle@^2.2.1:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/needle/-/needle-2.3.0.tgz#ce3fea21197267bacb310705a7bbe24f2a3a3492"
-  integrity sha512-QBZu7aAFR0522EyaXZM0FZ9GLpq6lvQ3uq8gteiDUp7wKdy0lSd2hPlgFwVuW1CBkfEs9PfDQsQzZghLs/psdg==
-  dependencies:
-    debug "^4.1.0"
-    iconv-lite "^0.4.4"
-    sax "^1.2.4"
-
 negotiator@0.6.2:
   version "0.6.2"
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
@@ -6521,26 +7110,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-pre-gyp@^0.12.0:
-  version "0.12.0"
-  resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz#39ba4bb1439da030295f899e3b520b7785766149"
-  integrity sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A==
-  dependencies:
-    detect-libc "^1.0.2"
-    mkdirp "^0.5.1"
-    needle "^2.2.1"
-    nopt "^4.0.1"
-    npm-packlist "^1.1.6"
-    npmlog "^4.0.2"
-    rc "^1.2.7"
-    rimraf "^2.6.1"
-    semver "^5.3.0"
-    tar "^4"
+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"
@@ -6555,17 +7133,9 @@
     chalk "~0.4.0"
     underscore "~1.6.0"
 
-nopt@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d"
-  integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=
-  dependencies:
-    abbrev "1"
-    osenv "^0.1.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"
+  resolved "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz"
   integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==
   dependencies:
     hosted-git-info "^2.1.4"
@@ -6573,6 +7143,16 @@
     semver "2 || 3 || 4 || 5"
     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==
+  dependencies:
+    hosted-git-info "^4.0.1"
+    resolve "^1.20.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"
@@ -6587,21 +7167,20 @@
 
 normalize-url@^4.1.0:
   version "4.5.0"
-  resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.0.tgz#453354087e6ca96957bd8f5baf753f5982142129"
+  resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz"
   integrity sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==
 
-npm-bundled@^1.0.1:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd"
-  integrity sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g==
-
-npm-packlist@^1.1.6:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.1.tgz#19064cdf988da80ea3cee45533879d90192bbfbc"
-  integrity sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw==
+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:
-    ignore-walk "^3.0.1"
-    npm-bundled "^1.0.1"
+    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"
@@ -6610,23 +7189,13 @@
   dependencies:
     path-key "^2.0.0"
 
-npm-run-path@^4.0.0:
+npm-run-path@^4.0.0, npm-run-path@^4.0.1:
   version "4.0.1"
-  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
+  resolved "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz"
   integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==
   dependencies:
     path-key "^3.0.0"
 
-npmlog@^4.0.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"
@@ -6642,11 +7211,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"
@@ -6656,14 +7220,14 @@
     define-property "^0.2.5"
     kind-of "^3.0.3"
 
-object-inspect@^1.7.0:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67"
-  integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==
+object-inspect@^1.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-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1:
+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"
+  resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz"
   integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
 
 object-visit@^1.0.0:
@@ -6673,20 +7237,15 @@
   dependencies:
     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==
+object.assign@^4.1.0, object.assign@^4.1.2:
+  version "4.1.2"
+  resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz"
+  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"
-
-object.entries-ponyfill@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/object.entries-ponyfill/-/object.entries-ponyfill-1.0.1.tgz#29abdf77cbfbd26566dd1aa24e9d88f65433d256"
-  integrity sha1-Kavfd8v70mVm3RqiTp2I9lQz0lY=
+    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"
@@ -6703,14 +7262,14 @@
   dependencies:
     isobject "^3.0.1"
 
-object.values@^1.1.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.1.tgz#68a99ecde356b7e9295a3c5e0ce31dc8c953de5e"
-  integrity sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA==
+object.values@^1.1.1:
+  version "1.1.3"
+  resolved "https://registry.npmjs.org/object.values/-/object.values-1.1.3.tgz"
+  integrity sha512-nkF6PfDB9alkOUxpf1HNm/QlkeW3SReqL5WXeBLpEJJnlPSvRaDQpW3gQTksTN3fgJX4hL42RzKyOin6ff3tyw==
   dependencies:
+    call-bind "^1.0.2"
     define-properties "^1.1.3"
-    es-abstract "^1.17.0-next.1"
-    function-bind "^1.1.1"
+    es-abstract "^1.18.0-next.2"
     has "^1.0.3"
 
 obuf@^1.0.0, obuf@^1.1.1:
@@ -6737,32 +7296,27 @@
 
 once@^1.3.0, once@^1.3.1, once@^1.4.0:
   version "1.4.0"
-  resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
+  resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz"
   integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
   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=
-  dependencies:
-    mimic-fn "^1.0.0"
-
-onetime@^5.1.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.0.tgz#fff0f3c91617fe62bb50189636e99ac8a6df7be5"
-  integrity sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==
+onetime@^5.1.0, onetime@^5.1.2:
+  version "5.1.2"
+  resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz"
+  integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==
   dependencies:
     mimic-fn "^2.1.0"
 
@@ -6773,25 +7327,17 @@
   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=
+optionator@^0.9.1:
+  version "0.9.1"
+  resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz"
+  integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==
   dependencies:
-    minimist "~0.0.1"
-    wordwrap "~0.0.2"
-
-optionator@^0.8.3:
-  version "0.8.3"
-  resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"
-  integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==
-  dependencies:
-    deep-is "~0.1.3"
-    fast-levenshtein "~2.0.6"
-    levn "~0.3.0"
-    prelude-ls "~1.1.2"
-    type-check "~0.3.2"
-    word-wrap "~1.2.3"
+    deep-is "^0.1.3"
+    fast-levenshtein "^2.0.6"
+    levn "^0.4.1"
+    prelude-ls "^1.2.1"
+    type-check "^0.4.0"
+    word-wrap "^1.2.3"
 
 ordered-read-streams@^0.3.0:
   version "0.3.0"
@@ -6806,7 +7352,7 @@
   resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
   integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
 
-os-name@^3.0.0:
+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==
@@ -6821,10 +7367,10 @@
 
 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.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
+  resolved "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz"
   integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
 
-osenv@^0.1.0, osenv@^0.1.3, osenv@^0.1.4:
+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==
@@ -6839,9 +7385,14 @@
 
 p-cancelable@^1.0.0:
   version "1.1.0"
-  resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc"
+  resolved "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz"
   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"
@@ -6849,28 +7400,21 @@
 
 p-limit@^1.1.0:
   version "1.3.0"
-  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8"
+  resolved "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz"
   integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==
   dependencies:
     p-try "^1.0.0"
 
-p-limit@^2.0.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.0.tgz#417c9941e6027a9abcba5092dd2904e255b5fbc2"
-  integrity sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==
-  dependencies:
-    p-try "^2.0.0"
-
-p-limit@^2.2.0:
+p-limit@^2.0.0, p-limit@^2.2.0:
   version "2.3.0"
-  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
+  resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz"
   integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
   dependencies:
     p-try "^2.0.0"
 
 p-locate@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43"
+  resolved "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz"
   integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=
   dependencies:
     p-limit "^1.1.0"
@@ -6884,7 +7428,7 @@
 
 p-locate@^4.1.0:
   version "4.1.0"
-  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
+  resolved "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz"
   integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
   dependencies:
     p-limit "^2.2.0"
@@ -6903,12 +7447,12 @@
 
 p-try@^1.0.0:
   version "1.0.0"
-  resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3"
+  resolved "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz"
   integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=
 
 p-try@^2.0.0, p-try@^2.1.0:
   version "2.2.0"
-  resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
+  resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz"
   integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
 
 package-json@^2.0.0:
@@ -6933,7 +7477,7 @@
 
 package-json@^6.3.0:
   version "6.5.0"
-  resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0"
+  resolved "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz"
   integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==
   dependencies:
     got "^9.6.0"
@@ -6941,6 +7485,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"
@@ -6955,7 +7506,7 @@
 
 parent-module@^1.0.0:
   version "1.0.1"
-  resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
+  resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz"
   integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==
   dependencies:
     callsites "^3.0.0"
@@ -6972,7 +7523,7 @@
 
 parse-json@^2.1.0, parse-json@^2.2.0:
   version "2.2.0"
-  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9"
+  resolved "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz"
   integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=
   dependencies:
     error-ex "^1.2.0"
@@ -6986,13 +7537,13 @@
     json-parse-better-errors "^1.0.1"
 
 parse-json@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.0.0.tgz#73e5114c986d143efa3712d4ea24db9a4266f60f"
-  integrity sha512-OOY5b7PAEFV0E2Fir1KOkxchnZNCdowAJgQ5NuxjpBKTRP3pQhwkrkxqQjeoKJ+fO7bCpmIZaogI4eZGDMEGOw==
+  version "5.2.0"
+  resolved "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz"
+  integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==
   dependencies:
     "@babel/code-frame" "^7.0.0"
     error-ex "^1.3.1"
-    json-parse-better-errors "^1.0.1"
+    json-parse-even-better-errors "^2.3.0"
     lines-and-columns "^1.1.6"
 
 parse-passwd@^1.0.0:
@@ -7005,19 +7556,15 @@
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608"
   integrity sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==
 
-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"
@@ -7043,17 +7590,17 @@
 
 path-exists@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
+  resolved "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz"
   integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=
 
 path-exists@^4.0.0:
   version "4.0.0"
-  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
+  resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz"
   integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
 
 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"
+  resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz"
   integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
 
 path-is-inside@^1.0.1, path-is-inside@^1.0.2:
@@ -7068,12 +7615,12 @@
 
 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"
+  resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz"
   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"
+  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:
@@ -7081,14 +7628,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:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.7.0.tgz#59fde0f435badacba103a84e9d3bc64e96b9937d"
-  integrity sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=
-  dependencies:
-    isarray "0.0.1"
-
-path-to-regexp@^1.7.0:
+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==
@@ -7106,7 +7646,7 @@
 
 path-type@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73"
+  resolved "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz"
   integrity sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=
   dependencies:
     pify "^2.0.0"
@@ -7118,6 +7658,11 @@
   dependencies:
     pify "^3.0.0"
 
+path-type@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz"
+  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"
@@ -7128,14 +7673,14 @@
     through2 "^2.0.3"
 
 pem@^1.8.3:
-  version "1.14.3"
-  resolved "https://registry.yarnpkg.com/pem/-/pem-1.14.3.tgz#347e5a5c194a5f7612b88083e45042fcc4fb4901"
-  integrity sha512-Q+AMVMD3fzeVvZs5PHeI+pVt0hgZY2fjhkliBW43qyONLgCXPVk1ryim43F9eupHlNGLJNT5T/NNrzhUdiC5Zg==
+  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 "^1.3.1"
+    which "^2.0.2"
 
 pend@~1.2.0:
   version "1.2.0"
@@ -7147,9 +7692,14 @@
   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:
   version "2.3.0"
-  resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
+  resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz"
   integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw=
 
 pify@^3.0.0:
@@ -7176,7 +7726,7 @@
 
 pkg-dir@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b"
+  resolved "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz"
   integrity sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=
   dependencies:
     find-up "^2.1.0"
@@ -7338,7 +7888,7 @@
 
 polymer-cli@^1.9.11:
   version "1.9.11"
-  resolved "https://registry.yarnpkg.com/polymer-cli/-/polymer-cli-1.9.11.tgz#0b5310732b787e07b811af96627ef0fd1263f5da"
+  resolved "https://registry.npmjs.org/polymer-cli/-/polymer-cli-1.9.11.tgz"
   integrity sha512-tiURjHDCOUUtDVPuVYvrfFI9PXe4OOUmBbn6Sg5GJNQ2POtP7r7hv+I5yI8P9qsxmalHTa19chVtf5/t9IBXDg==
   dependencies:
     "@octokit/rest" "^16.2.0"
@@ -7470,10 +8020,10 @@
   resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
   integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=
 
-prelude-ls@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
-  integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=
+prelude-ls@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
+  integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
 
 prepend-http@^1.0.1:
   version "1.0.4"
@@ -7482,7 +8032,7 @@
 
 prepend-http@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
+  resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz"
   integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=
 
 preserve@^0.2.0:
@@ -7492,30 +8042,25 @@
 
 prettier-linter-helpers@^1.0.0:
   version "1.0.0"
-  resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b"
+  resolved "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz"
   integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==
   dependencies:
     fast-diff "^1.1.2"
 
-prettier@2.0.5, prettier@^2.0.4:
-  version "2.0.5"
-  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.0.5.tgz#d6d56282455243f2f92cc1716692c08aa31522d4"
-  integrity sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg==
+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==
 
 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=
 
-pretty-bytes@^5.1.0:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.2.0.tgz#96c92c6e95a0b35059253fb33c03e260d40f5a1f"
-  integrity sha512-ujANBhiUsl9AhREUDUEY1GPOharMGm8x8juS7qOHybcLi7XsKfrYQ88hSly1l2i0klXHTDYrlL8ihMCG55Dc3w==
-
-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"
@@ -7524,12 +8069,12 @@
 
 progress@2.0.3, progress@^2.0.0:
   version "2.0.3"
-  resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
+  resolved "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz"
   integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
 
 protobufjs@6.8.8:
   version "6.8.8"
-  resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.8.8.tgz#c8b4f1282fd7a90e6f5b109ed11c84af82908e7c"
+  resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz"
   integrity sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==
   dependencies:
     "@protobufjs/aspromise" "^1.1.2"
@@ -7547,22 +8092,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.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.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.2.0"
-  resolved "https://registry.yarnpkg.com/psl/-/psl-1.2.0.tgz#df12b5b1b3a30f51c329eacbdef98f3a6e136dc6"
-  integrity sha512-GEn74ZffufCmkDDLNcl3uuyF/aSD6exEyh1v/ZSdAomB82t6G9hzJVRx0jBmLDW+VfZqks3aScmMw9DszwUalA==
+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"
@@ -7582,7 +8127,7 @@
 
 pump@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
+  resolved "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz"
   integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==
   dependencies:
     end-of-stream "^1.1.0"
@@ -7602,15 +8147,15 @@
   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"
+  resolved "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
-pupa@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.0.1.tgz#dbdc9ff48ffbea4a26a069b6f9f7abb051008726"
-  integrity sha512-hEJH0s8PXLY/cdXh66tNEQGndDrIKNqNC5xmrysZy3i5C3oEoLna7YAOad+7u125+zH1HNXUmGEkrhb3c2VriA==
+pupa@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.npmjs.org/pupa/-/pupa-2.1.1.tgz"
+  integrity sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==
   dependencies:
     escape-goat "^2.0.0"
 
@@ -7629,11 +8174,21 @@
   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"
+  integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+
 quick-lru@^4.0.1:
   version "4.0.1"
-  resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"
+  resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz"
   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"
@@ -7658,9 +8213,9 @@
     iconv-lite "0.4.24"
     unpipe "1.0.0"
 
-rc@^1.0.1, rc@^1.1.6, rc@^1.2.7, rc@^1.2.8:
+rc@^1.0.1, rc@^1.1.6, rc@^1.2.8:
   version "1.2.8"
-  resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
+  resolved "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz"
   integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
   dependencies:
     deep-extend "^0.6.0"
@@ -7676,7 +8231,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==
@@ -7694,7 +8249,7 @@
 
 read-pkg-up@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be"
+  resolved "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz"
   integrity sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=
   dependencies:
     find-up "^2.0.0"
@@ -7708,9 +8263,17 @@
     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.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507"
+  resolved "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz"
   integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==
   dependencies:
     find-up "^4.1.0"
@@ -7728,7 +8291,7 @@
 
 read-pkg@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8"
+  resolved "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz"
   integrity sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=
   dependencies:
     load-json-file "^2.0.0"
@@ -7744,9 +8307,9 @@
     normalize-package-data "^2.3.2"
     path-type "^3.0.0"
 
-read-pkg@^5.2.0:
+read-pkg@^5.0.0, read-pkg@^5.2.0:
   version "5.2.0"
-  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc"
+  resolved "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz"
   integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==
   dependencies:
     "@types/normalize-package-data" "^2.4.0"
@@ -7764,18 +8327,14 @@
     isarray "0.0.1"
     string_decoder "~0.10.x"
 
-"readable-stream@2 || 3", 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.0.6, readable-stream@^2.3.0, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6:
-  version "2.3.6"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
-  integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==
+"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:
-    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"
+    inherits "^2.0.3"
+    string_decoder "^1.1.1"
+    util-deprecate "^1.0.1"
 
 "readable-stream@>=1.0.33-1 <1.1.0-0":
   version "1.0.34"
@@ -7787,7 +8346,7 @@
     isarray "0.0.1"
     string_decoder "~0.10.x"
 
-readable-stream@^2.2.2, readable-stream@^2.2.9:
+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==
@@ -7800,15 +8359,6 @@
     string_decoder "~1.1.1"
     util-deprecate "~1.0.1"
 
-readable-stream@^3.0.1, readable-stream@^3.1.1, readable-stream@^3.4.0:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.4.0.tgz#a51c26754658e0a3c21dbf59163bd45ba6f447fc"
-  integrity sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==
-  dependencies:
-    inherits "^2.0.3"
-    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"
@@ -7835,7 +8385,7 @@
 
 redent@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f"
+  resolved "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz"
   integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==
   dependencies:
     indent-string "^4.0.0"
@@ -7846,29 +8396,34 @@
   resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-1.0.1.tgz#258c78efd153ddf93cb561237f61184f3696e327"
   integrity sha1-JYx479FT3fk8tWEjf2EYTzaW4yc=
 
-regenerate-unicode-properties@^8.0.2:
-  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.0"
-  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.0.tgz#2ca9aaf7a2c239dd32e4761218425b8c7a86ecaf"
-  integrity sha512-rtOelq4Cawlbmq9xuMR5gdFmv7ku/sFoB7sRiywx7aq53bc52b4j6zvH7Te1Vt/X2YveDKnCGUbioieU7FEL3w==
+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:
-    private "^0.1.6"
+    "@babel/runtime" "^7.8.4"
 
 regex-cache@^0.4.2:
   version "0.4.4"
@@ -7885,32 +8440,27 @@
     extend-shallow "^3.0.2"
     safe-regex "^1.1.0"
 
-regexpp@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f"
-  integrity sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==
-
-regexpp@^3.0.0:
+regexpp@^3.0.0, regexpp@^3.1.0:
   version "3.1.0"
-  resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2"
+  resolved "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz"
   integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==
 
-regexpu-core@^4.5.4:
-  version "4.5.4"
-  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.5.4.tgz#080d9d02289aa87fe1667a4f5136bc98a6aebaae"
-  integrity sha512-BtizvGtFQKGPUcTy56o3nk1bGRp4SZOTYrDtGNlqCQufptV5IkkLN6Emw+yunAJjzf+C9FQFtvq7IoA3+oMYHQ==
+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.0.2"
-    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"
 
-regextras@^0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/regextras/-/regextras-0.7.0.tgz#2298bef8cfb92b1b7e3b9b12aa8f69547b7d71e4"
-  integrity sha512-ds+fL+Vhl918gbAUb0k2gVKbTZLsg84Re3DI6p85Et0U0tYME3hyW4nMK8Px4dtDaBA2qNjvG5uWyW7eK5gfmw==
+regextras@^0.7.1:
+  version "0.7.1"
+  resolved "https://registry.npmjs.org/regextras/-/regextras-0.7.1.tgz"
+  integrity sha512-9YXf6xtW+qzQ+hcMQXx95MOvfqXFgsKDZodX3qZB0x2n5Z94ioetIITsBtvJbiOyxa/6s9AtyweBLCdPmPko/w==
 
 registry-auth-token@^3.0.1:
   version "3.4.0"
@@ -7921,9 +8471,9 @@
     safe-buffer "^5.0.1"
 
 registry-auth-token@^4.0.0:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.1.1.tgz#40a33be1e82539460f94328b0f7f0f84c16d9479"
-  integrity sha512-9bKS7nTl9+/A1s7tnPeGrUpRcVY+LUh7bfFgzpndALdPfXQBfQV77rQVtqgUV3ti4vc/Ik81Ex8UJDWDQ12zQA==
+  version "4.2.1"
+  resolved "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.2.1.tgz"
+  integrity sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw==
   dependencies:
     rc "^1.2.8"
 
@@ -7936,20 +8486,20 @@
 
 registry-url@^5.0.0:
   version "5.1.0"
-  resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009"
+  resolved "https://registry.npmjs.org/registry-url/-/registry-url-5.1.0.tgz"
   integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==
   dependencies:
     rc "^1.2.8"
 
-regjsgen@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.0.tgz#a7634dc08f89209c2049adda3525711fb97265dd"
-  integrity sha512-RnIrLhrXCX5ow/E5/Mh2O4e/oa1/jW0eaBKTSy3LaCj+M3Bqvm97GWDp2yUtzIs4LEn65zR2yiYGFqb2ApnzDA==
+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.0"
-  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.0.tgz#f1e6ae8b7da2bae96c99399b868cd6c933a2ba9c"
-  integrity sha512-RQ7YyokLiQBomUJuUG8iGVvkgOLxwyZM8k6d3q5SAXpg4r5TZJZigKFvC6PpD+qQ98bCDC5YelPeA3EucDoNeQ==
+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"
 
@@ -7964,9 +8514,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"
@@ -7986,11 +8536,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==
@@ -8016,6 +8566,37 @@
     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-from-string@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz"
+  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"
@@ -8026,6 +8607,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.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"
@@ -8044,7 +8630,7 @@
 
 resolve-from@^4.0.0:
   version "4.0.0"
-  resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
+  resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz"
   integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
 
 resolve-url@^0.2.1:
@@ -8052,41 +8638,28 @@
   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.3.2:
-  version "1.11.1"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.11.1.tgz#ea10d8110376982fef578df8fc30b9ac30a07a3e"
-  integrity sha512-vIpgF6wfuJOZI7KKKSP+HmiKggadPQAdsp5HiC1mvqnfp0gF1vdwgBWZIdrVft9pgqoMFQN+R7BSWZiBxx+BBw==
+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:
+  version "1.20.0"
+  resolved "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz"
+  integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
   dependencies:
-    path-parse "^1.0.6"
-
-resolve@^1.10.1:
-  version "1.17.0"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444"
-  integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==
-  dependencies:
-    path-parse "^1.0.6"
-
-resolve@^1.12.0, resolve@^1.13.1:
-  version "1.15.1"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.1.tgz#27bdcdeffeaf2d6244b95bb0f9f4b4653451f3e8"
-  integrity sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w==
-  dependencies:
-    path-parse "^1.0.6"
-
-resolve@^1.5.0:
-  version "1.14.2"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.14.2.tgz#dbf31d0fa98b1f29aa5169783b9c290cb865fea2"
-  integrity sha512-EjlOBLBO1kxsUxsKjLt7TAECyKW6fOh1VRkykQkKGzcBbjjPIxBqGh0jf7GJ3k/f5mxMqW3htMD3WdTUVtW8HQ==
-  dependencies:
+    is-core-module "^2.2.0"
     path-parse "^1.0.6"
 
 responselike@^1.0.2:
   version "1.0.2"
-  resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7"
+  resolved "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz"
   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"
@@ -8095,17 +8668,9 @@
     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=
-  dependencies:
-    onetime "^2.0.0"
-    signal-exit "^3.0.2"
-
 restore-cursor@^3.1.0:
   version "3.1.0"
-  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
+  resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz"
   integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==
   dependencies:
     onetime "^5.1.0"
@@ -8116,88 +8681,81 @@
   resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
   integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
 
-rimraf@2.6.3, rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@~2.6.2:
+reusify@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz"
+  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:
+  version "3.0.2"
+  resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz"
+  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"
 
-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==
-  dependencies:
-    glob "^7.1.3"
-
-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"
-
-rimraf@~2.2.6:
-  version "2.2.8"
-  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.2.8.tgz#e439be2aaee327321952730f99a8929e4fc50582"
-  integrity sha1-5Dm+Kq7jJzIZUnMPmaiSnk/FBYI=
-
 rollup@^1.3.0:
-  version "1.16.7"
-  resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.16.7.tgz#4b539ca22465df39f6c963d2001d95f6527e97e1"
-  integrity sha512-P3GVcbVSLLjHWFLKGerYRe3Q/yggRXmTZFx/4WZf4wzGwO6hAg5jyMAFMQKc0dts8rFID4BQngfoz6yQbI7iMQ==
+  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" "0.0.39"
-    "@types/node" "^12.0.10"
-    acorn "^6.1.1"
+    "@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==
+rollup@^2.45.2:
+  version "2.45.2"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.45.2.tgz#8fb85917c9f35605720e92328f3ccbfba6f78b48"
+  integrity sha512-kRRU7wXzFHUzBIv0GfoFFIN3m9oteY4uAsKllIpQDId5cfnkWF2J130l+27dzDju0E6MScKiV0ZM5Bw8m4blYQ==
   optionalDependencies:
-    fsevents "~2.1.2"
+    fsevents "~2.3.1"
 
-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.4.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"
+  resolved "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz"
   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"
+  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:
-  version "6.5.2"
-  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.2.tgz#2e35ce815cd46d84d02a209fb4e5921e051dbec7"
-  integrity sha512-HUb7j3kvb7p7eCUHE3FqjoDsC1xfZQ4AHFWfTKSpZ+sAhhz5X1WX0ZuUqWbzB2QhSLp3DoLUG+hMdEDKqWo2Zg==
+rxjs@^6.4.0, rxjs@^6.6.0:
+  version "6.6.7"
+  resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz"
+  integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==
   dependencies:
     tslib "^1.9.0"
 
-rxjs@^6.5.3:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c"
-  integrity sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==
-  dependencies:
-    tslib "^1.9.0"
-
-safe-buffer@5.1.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.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.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.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"
@@ -8208,7 +8766,7 @@
 
 "safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
   version "2.1.2"
-  resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
+  resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
 samsam@1.x, samsam@^1.1.3:
@@ -8217,21 +8775,16 @@
   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"
 
-sax@^1.2.4:
-  version "1.2.4"
-  resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
-  integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
-
 scoped-regex@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/scoped-regex/-/scoped-regex-1.0.0.tgz#a346bb1acd4207ae70bd7c0c7ca9e566b6baddb8"
@@ -8243,22 +8796,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.23.0"
+  resolved "https://registry.yarnpkg.com/selenium-standalone/-/selenium-standalone-6.23.0.tgz#91a7d12b1c8ba077a82b44323445c5882eb20ff1"
+  integrity sha512-6dVLSEvbixd/MRSEmrcRQD8dmABrzNsxRqroKFQY+RVzm1JVPgGHIlo6qJzG6akfjc2V8SadHslE6lN4BFVM3w==
   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.0"
+    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.1.4"
+    which "^2.0.2"
     yauzl "^2.10.0"
 
 semver-diff@^2.0.0:
@@ -8270,26 +8822,33 @@
 
 semver-diff@^3.1.1:
   version "3.1.1"
-  resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b"
+  resolved "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz"
   integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==
   dependencies:
     semver "^6.3.0"
 
-"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.6.0:
-  version "5.7.0"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b"
-  integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==
-
-semver@5.6.0, semver@^5.0.3, semver@^5.1.0, semver@^5.5.0:
+"semver@2 || 3 || 4 || 5", semver@5.6.0, semver@^5.3.0:
   version "5.6.0"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004"
+  resolved "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz"
   integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==
 
-semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0:
+semver@^5.0.3, semver@^5.1.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==
+
+semver@^6.0.0, semver@^6.1.0, semver@^6.2.0, semver@^6.3.0:
   version "6.3.0"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
+  resolved "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz"
   integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
 
+semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4:
+  version "7.3.5"
+  resolved "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz"
+  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"
@@ -8348,10 +8907,12 @@
   resolved "https://registry.yarnpkg.com/serviceworker-cache-polyfill/-/serviceworker-cache-polyfill-4.0.0.tgz#de19ee73bef21ab3c0740a37b33db62464babdeb"
   integrity sha1-3hnuc77yGrPAdAo3sz22JGS6ves=
 
-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-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-value@^2.0.0, set-value@^2.0.1:
   version "2.0.1"
@@ -8378,6 +8939,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"
@@ -8387,7 +8955,7 @@
 
 shebang-command@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
+  resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz"
   integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
   dependencies:
     shebang-regex "^3.0.0"
@@ -8399,22 +8967,22 @@
 
 shebang-regex@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
+  resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz"
   integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
 
-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==
+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=
+signal-exit@^3.0.0, 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"
+  integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
 
 simple-swizzle@^0.2.2:
   version "0.2.2"
@@ -8447,14 +9015,24 @@
   resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
   integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=
 
-slice-ansi@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636"
-  integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==
+slash@^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"
+  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"
+  integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==
   dependencies:
-    ansi-styles "^3.2.0"
-    astral-regex "^1.0.0"
-    is-fullwidth-code-point "^2.0.0"
+    ansi-styles "^4.0.0"
+    astral-regex "^2.0.0"
+    is-fullwidth-code-point "^3.0.0"
 
 slide@^1.1.5:
   version "1.1.6"
@@ -8496,54 +9074,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:
@@ -8573,13 +9148,13 @@
 
 source-map-support@0.5.9:
   version "0.5.9"
-  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.9.tgz#41bc953b2534267ea2d605bccfa7bfa3111ced5f"
+  resolved "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.9.tgz"
   integrity sha512-gR6Rw4MvUlYy83vP0vxoVNzM6t8MUXqNuRsuBmBHQDu1Fh6X015FrLdgoDKcNdkwGubozq0P4N0Q37UyFVr1EA==
   dependencies:
     buffer-from "^1.0.0"
     source-map "^0.6.0"
 
-source-map-support@~0.5.12:
+source-map-support@~0.5.19:
   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==
@@ -8588,9 +9163,9 @@
     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"
@@ -8599,9 +9174,14 @@
 
 source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
   version "0.6.1"
-  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+  resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz"
   integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
 
+source-map@~0.7.2:
+  version "0.7.3"
+  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"
@@ -8611,30 +9191,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.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz"
+  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.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz"
+  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==
+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"
+  integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==
   dependencies:
     spdx-exceptions "^2.1.0"
     spdx-license-ids "^3.0.0"
 
 spdx-license-ids@^3.0.0:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.4.tgz#75ecd1a88de8c184ef015eafb51b5b48bfd11bb1"
-  integrity sha512-7j8LYJLeY/Yb6ACbQ7F76qy5jHkp0U6jgBfJsk97bwWlVUnUWsAgpyaCvo17h0/RQGnQ036tVDomiwoI4pDkQA==
+  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"
@@ -8670,7 +9250,7 @@
 
 sprintf-js@~1.0.2:
   version "1.0.3"
-  resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
+  resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz"
   integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
 
 sshpk@^1.7.0:
@@ -8714,20 +9294,20 @@
     define-property "^0.2.5"
     object-copy "^0.1.0"
 
-"statuses@>= 1.4.0 < 2", 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==
-
-"statuses@>= 1.5.0 < 2", statuses@~1.5.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.0"
-  resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952"
-  integrity sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=
+  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"
@@ -8746,7 +9326,7 @@
   resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add"
   integrity sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0=
 
-string-width@^1.0.1, "string-width@^1.0.2 || 2":
+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=
@@ -8755,7 +9335,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==
@@ -8765,37 +9345,37 @@
 
 string-width@^3.0.0:
   version "3.1.0"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
+  resolved "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz"
   integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==
   dependencies:
     emoji-regex "^7.0.1"
     is-fullwidth-code-point "^2.0.0"
     strip-ansi "^5.1.0"
 
-string-width@^4.0.0, string-width@^4.1.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5"
-  integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==
+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"
+  integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==
   dependencies:
     emoji-regex "^8.0.0"
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.0"
 
-string.prototype.trimleft@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz#9bdb8ac6abd6d602b17a4ed321870d2f8dcefc74"
-  integrity sha512-iu2AGd3PuP5Rp7x2kEZCrB2Nf41ehzh+goo8TV7z8/XDBbsvc6HQIlUl9RjkZ4oyrW1XM5UwlGl1oVEaDjg6Ag==
+string.prototype.trimend@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz"
+  integrity sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==
   dependencies:
+    call-bind "^1.0.2"
     define-properties "^1.1.3"
-    function-bind "^1.1.1"
 
-string.prototype.trimright@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/string.prototype.trimright/-/string.prototype.trimright-2.1.1.tgz#440314b15996c866ce8a0341894d45186200c5d9"
-  integrity sha512-qFvWL3/+QIgZXVmJBfpHmxLB7xsUXz6HsUmP8+5dRaC3Q7oKUv9Vo6aMCRZC1smrtyECFsIT30PqBJ1gTjAs+g==
+string.prototype.trimstart@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz"
+  integrity sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==
   dependencies:
+    call-bind "^1.0.2"
     define-properties "^1.1.3"
-    function-bind "^1.1.1"
 
 string_decoder@^1.1.1:
   version "1.3.0"
@@ -8816,7 +9396,7 @@
   dependencies:
     safe-buffer "~5.1.0"
 
-strip-ansi@^3.0.0, strip-ansi@^3.0.1:
+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=
@@ -8830,16 +9410,16 @@
   dependencies:
     ansi-regex "^3.0.0"
 
-strip-ansi@^5.1.0, strip-ansi@^5.2.0:
+strip-ansi@^5.1.0:
   version "5.2.0"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
+  resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz"
   integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==
   dependencies:
     ansi-regex "^4.1.0"
 
 strip-ansi@^6.0.0:
   version "6.0.0"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532"
+  resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz"
   integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==
   dependencies:
     ansi-regex "^5.0.0"
@@ -8849,6 +9429,13 @@
   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"
@@ -8874,7 +9461,7 @@
 
 strip-bom@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
+  resolved "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz"
   integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=
 
 strip-eof@^1.0.0:
@@ -8884,7 +9471,7 @@
 
 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"
+  resolved "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz"
   integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
 
 strip-indent@^1.0.1:
@@ -8901,19 +9488,19 @@
 
 strip-indent@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001"
+  resolved "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz"
   integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==
   dependencies:
     min-indent "^1.0.0"
 
-strip-json-comments@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.0.1.tgz#85713975a91fb87bf1b305cca77395e40d2a64a7"
-  integrity sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==
+strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz"
+  integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
 
 strip-json-comments@~2.0.1:
   version "2.0.1"
-  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
+  resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz"
   integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
 
 supports-color@^2.0.0:
@@ -8923,15 +9510,15 @@
 
 supports-color@^5.3.0:
   version "5.5.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
+  resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz"
   integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
   dependencies:
     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.npmjs.org/supports-color/-/supports-color-7.2.0.tgz"
+  integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
   dependencies:
     has-flag "^4.0.0"
 
@@ -8970,15 +9557,20 @@
     typical "^2.6.1"
     wordwrapjs "^3.0.0"
 
-table@^5.2.3:
-  version "5.4.6"
-  resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e"
-  integrity sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==
+table@^6.0.4:
+  version "6.0.9"
+  resolved "https://registry.npmjs.org/table/-/table-6.0.9.tgz"
+  integrity sha512-F3cLs9a3hL1Z7N4+EkSscsel3z55XT950AvB05bwayrNg5T1/gykXtigioTAjbltvbMSJvvhFCbnf6mX+ntnJQ==
   dependencies:
-    ajv "^6.10.2"
-    lodash "^4.17.14"
-    slice-ansi "^2.1.0"
-    string-width "^3.0.0"
+    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"
@@ -8990,12 +9582,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.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 "^2.2.0"
+    bl "^4.0.3"
     end-of-stream "^1.4.1"
     fs-constants "^1.0.0"
     inherits "^2.0.3"
@@ -9015,44 +9607,23 @@
     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==
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
+  integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
   dependencies:
-    bl "^3.0.0"
+    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@^4:
-  version "4.4.8"
-  resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.8.tgz#b19eec3fde2a96e64666df9fdb40c5ca1bc3747d"
-  integrity sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ==
-  dependencies:
-    chownr "^1.1.1"
-    fs-minipass "^1.2.5"
-    minipass "^2.3.4"
-    minizlib "^1.1.1"
-    mkdirp "^0.5.0"
-    safe-buffer "^5.1.2"
-    yallist "^3.0.2"
-
-temp@^0.8.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"
 
-temp@^0.8.3:
-  version "0.8.3"
-  resolved "https://registry.yarnpkg.com/temp/-/temp-0.8.3.tgz#e0c6bc4d26b903124410e4fed81103014dfc1f59"
-  integrity sha1-4Ma8TSa5AxJEEOT+2BEDAU38H1k=
-  dependencies:
-    os-tmpdir "^1.0.0"
-    rimraf "~2.2.6"
-
 term-size@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69"
@@ -9060,29 +9631,24 @@
   dependencies:
     execa "^0.7.0"
 
-term-size@^2.1.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.0.tgz#1f16adedfe9bdc18800e1776821734086fcc6753"
-  integrity sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw==
-
 ternary-stream@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/ternary-stream/-/ternary-stream-2.0.1.tgz#064e489b4b5bf60ba6a6b7bc7f2f5c274ecf8269"
-  integrity sha1-Bk5Im0tb9gumpre8fy9cJ07Pgmk=
+  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"
 
-terser@^4.8.0:
-  version "4.8.0"
-  resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17"
-  integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==
+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==
   dependencies:
     commander "^2.20.0"
-    source-map "~0.6.1"
-    source-map-support "~0.5.12"
+    source-map "~0.7.2"
+    source-map-support "~0.5.19"
 
 text-encoding@0.6.4:
   version "0.6.4"
@@ -9096,13 +9662,13 @@
 
 text-table@^0.2.0:
   version "0.2.0"
-  resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
+  resolved "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz"
   integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
 
-textextensions@^2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/textextensions/-/textextensions-2.4.0.tgz#6a143a985464384cc2cff11aea448cd5b018e72b"
-  integrity sha512-qftQXnX1DzpSV8EddtHIT0eDDEiBF8ywhFYR2lI9xrGtxqKN+CvLXhACeCIGbCpQfxxERbrkZEFb8cZcDKbVZA==
+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"
@@ -9112,9 +9678,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"
 
@@ -9150,16 +9716,17 @@
     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"
+  resolved "https://registry.npmjs.org/through/-/through-2.3.8.tgz"
   integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
 
 timed-out@^3.0.0:
@@ -9181,7 +9748,7 @@
 
 tmp@^0.0.33:
   version "0.0.33"
-  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
+  resolved "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz"
   integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==
   dependencies:
     os-tmpdir "~1.0.2"
@@ -9222,7 +9789,7 @@
 
 to-readable-stream@^1.0.0:
   version "1.0.0"
-  resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771"
+  resolved "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz"
   integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==
 
 to-regex-range@^2.1.0:
@@ -9233,6 +9800,13 @@
     is-number "^3.0.0"
     repeat-string "^1.6.1"
 
+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"
+  integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
+  dependencies:
+    is-number "^7.0.0"
+
 to-regex@^3.0.1, to-regex@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce"
@@ -9256,6 +9830,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"
@@ -9270,7 +9852,7 @@
 
 trim-newlines@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.0.tgz#79726304a6a898aa8373427298d54c2ee8b1cb30"
+  resolved "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.0.tgz"
   integrity sha512-C4+gOpvmxaSMKuEf9Qc134F1ZuOHVXKRbtEflf4NTtuuJDEIJ9p5PXsalL8SkeRw+qit1Mo+yuvMPAKwWg/1hA==
 
 trim-right@^1.0.1:
@@ -9283,30 +9865,28 @@
   resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
   integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
 
-tslib@^1.8.1:
-  version "1.13.0"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
-  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
+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==
+  dependencies:
+    "@types/json5" "^0.0.29"
+    json5 "^1.0.1"
+    minimist "^1.2.0"
+    strip-bom "^3.0.0"
 
-tslib@^1.9.0:
-  version "1.10.0"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
-  integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
+tslib@^1.8.1, tslib@^1.9.0:
+  version "1.14.1"
+  resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
+  integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
-tsutils@3.21.0:
+tsutils@3.21.0, tsutils@^3.17.1:
   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"
 
-tsutils@^3.17.1:
-  version "3.17.1"
-  resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759"
-  integrity sha512-kzeQ5B8H3w60nFY2g8cJIuH7JDpsALXySGtwGJ0p2LSjLgay3NdIpqq5SoOBe46bKDW2iq25irHCr8wjomUS2g==
-  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"
@@ -9319,31 +9899,41 @@
   resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
   integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
 
-type-check@~0.3.2:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"
-  integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=
+type-check@^0.4.0, type-check@~0.4.0:
+  version "0.4.0"
+  resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz"
+  integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==
   dependencies:
-    prelude-ls "~1.1.2"
+    prelude-ls "^1.2.1"
 
 type-detect@^4.0.0:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
   integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
 
-type-fest@^0.13.1:
-  version "0.13.1"
-  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934"
-  integrity sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==
+type-fest@^0.18.0:
+  version "0.18.1"
+  resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz"
+  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"
+  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"
+  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"
+  resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz"
   integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==
 
 type-fest@^0.8.1:
   version "0.8.1"
-  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
+  resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz"
   integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==
 
 type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18:
@@ -9356,7 +9946,7 @@
 
 typedarray-to-buffer@^3.1.5:
   version "3.1.5"
-  resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
+  resolved "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz"
   integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==
   dependencies:
     is-typedarray "^1.0.0"
@@ -9366,10 +9956,10 @@
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
   integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
 
-typescript@3.9.5:
-  version "3.9.5"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.5.tgz#586f0dba300cde8be52dd1ac4f7e1009c1b13f36"
-  integrity sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==
+typescript@4.1.4:
+  version "4.1.4"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.4.tgz#f058636e2f4f83f94ddaae07b20fd5e14598432f"
+  integrity sha512-+Uru0t8qIRgjuCpiSPpfGuhHecMllk5Zsazj5LZvVsEStEjmIRRBZe+jHjGQvsgS7M1wONy2PQXd67EMyV6acg==
 
 typical@^2.6.1:
   version "2.6.1"
@@ -9382,9 +9972,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"
@@ -9394,10 +9984,20 @@
     commander "~2.19.0"
     source-map "~0.6.1"
 
+unbox-primitive@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz"
+  integrity sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==
+  dependencies:
+    function-bind "^1.1.1"
+    has-bigints "^1.0.1"
+    has-symbols "^1.0.2"
+    which-boxed-primitive "^1.0.2"
+
 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.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"
@@ -9417,15 +10017,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"
@@ -9454,17 +10054,22 @@
 
 unique-string@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d"
+  resolved "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz"
   integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==
   dependencies:
     crypto-random-string "^2.0.0"
 
-universal-user-agent@^2.0.0, universal-user-agent@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-2.1.0.tgz#5abfbcc036a1ba490cb941f8fd68c46d3669e8e4"
-  integrity sha512-8itiX7G05Tu3mGDTdNY2fB4KJ8MgZLS54RdG6PkkfwMAavrXu1mV/lls/GABx9O3Rw4PnTtasxrvbMQoBYY92Q==
+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.0.0"
+    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"
@@ -9531,22 +10136,23 @@
     semver-diff "^2.0.0"
     xdg-basedir "^3.0.0"
 
-update-notifier@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-4.1.0.tgz#4866b98c3bc5b5473c020b1250583628f9a328f3"
-  integrity sha512-w3doE1qtI0/ZmgeoDoARmI5fjDoT93IfKgEGqm26dGUOh8oNpaSTsGNdYRN/SjOuo10jcJGwkEL3mroKzktkew==
+update-notifier@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.npmjs.org/update-notifier/-/update-notifier-5.1.0.tgz"
+  integrity sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw==
   dependencies:
-    boxen "^4.2.0"
-    chalk "^3.0.0"
+    boxen "^5.0.0"
+    chalk "^4.1.0"
     configstore "^5.0.1"
     has-yarn "^2.1.0"
     import-lazy "^2.1.0"
     is-ci "^2.0.0"
-    is-installed-globally "^0.3.1"
-    is-npm "^4.0.0"
+    is-installed-globally "^0.4.0"
+    is-npm "^5.0.0"
     is-yarn-global "^0.3.0"
-    latest-version "^5.0.0"
-    pupa "^2.0.1"
+    latest-version "^5.1.0"
+    pupa "^2.1.1"
+    semver "^7.3.4"
     semver-diff "^3.1.1"
     xdg-basedir "^4.0.0"
 
@@ -9556,21 +10162,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.npmjs.org/uri-js/-/uri-js-4.4.1.tgz"
+  integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
   dependencies:
     punycode "^2.1.0"
 
 urijs@^1.16.1:
-  version "1.19.1"
-  resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.1.tgz#5b0ff530c0cbde8386f6342235ba5ca6e995d25a"
-  integrity sha512-xVrGVi94ueCJNrBSTjWqjvtgvl3cyOTThp2zaMaFNGp3F542TR6sM3f2o8RqZl+AwteClSVmoCyt0ka4RjQOQg==
-
-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==
+  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"
@@ -9586,16 +10187,11 @@
 
 url-parse-lax@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c"
+  resolved "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz"
   integrity sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=
   dependencies:
     prepend-http "^2.0.0"
 
-url-template@^2.0.8:
-  version "2.0.8"
-  resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21"
-  integrity sha1-/FZaPMy/93MMd19WQflVV5FDnyE=
-
 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"
@@ -9622,14 +10218,14 @@
   integrity sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=
 
 uuid@^3.2.1, uuid@^3.3.2:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
-  integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==
+  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.1.0"
-  resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e"
-  integrity sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g==
+  version "2.3.0"
+  resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz"
+  integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==
 
 vali-date@^1.0.0:
   version "1.0.0"
@@ -9647,7 +10243,7 @@
 
 validate-npm-package-license@^3.0.1:
   version "3.0.4"
-  resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
+  resolved "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz"
   integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==
   dependencies:
     spdx-correct "^3.0.0"
@@ -9672,17 +10268,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"
@@ -9707,7 +10302,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=
@@ -9716,10 +10311,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"
@@ -9775,9 +10370,9 @@
     uuid "^3.2.1"
 
 wd@^1.2.0:
-  version "1.12.0"
-  resolved "https://registry.yarnpkg.com/wd/-/wd-1.12.0.tgz#763ce9ebcaa854ab8e05d0a660f24da839674329"
-  integrity sha512-P8fOXV+FAxR7Pdto4sQ982ud6xUJHzxfY/JYH+NfBJ2EYJhGeO7fLXiVKosZK3YlZK7EQfPmUVqkbEzNlGTGgQ==
+  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"
@@ -9836,27 +10431,31 @@
     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-boxed-primitive@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz"
+  integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==
+  dependencies:
+    is-bigint "^1.0.1"
+    is-boolean-object "^1.1.0"
+    is-number-object "^1.0.4"
+    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@^2.0.1:
+which@^2.0.1, which@^2.0.2:
   version "2.0.2"
-  resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
+  resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"
   integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
   dependencies:
     isexe "^2.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"
-
 widest-line@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-1.0.0.tgz#0c09c85c2a94683d0d7eaf8ee097d564bf0e105c"
@@ -9873,56 +10472,56 @@
 
 widest-line@^3.1.0:
   version "3.1.0"
-  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca"
+  resolved "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz"
   integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==
   dependencies:
     string-width "^4.0.0"
 
 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.6"
-  resolved "https://registry.yarnpkg.com/with-open-file/-/with-open-file-0.1.6.tgz#0bc178ecab75f6baac8ae11c85e07445d690ea50"
-  integrity sha512-SQS05JekbtwQSgCYlBsZn/+m2gpn4zWsqpCYIrCHva0+ojXcnmUEPsBN6Ipoz3vmY/81k5PvYEWSxER2g4BTqA==
+  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:
+word-wrap@^1.2.3:
   version "1.2.3"
-  resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
+  resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz"
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 
-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=
@@ -9935,9 +10534,18 @@
     reduce-flatten "^1.0.1"
     typical "^2.6.1"
 
+wrap-ansi@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
+  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+  dependencies:
+    ansi-styles "^4.0.0"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.0"
+
 wrappy@1:
   version "1.0.2"
-  resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
+  resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
   integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
 
 write-file-atomic@^1.1.2:
@@ -9960,7 +10568,7 @@
 
 write-file-atomic@^3.0.0, write-file-atomic@^3.0.3:
   version "3.0.3"
-  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8"
+  resolved "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz"
   integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==
   dependencies:
     imurmurhash "^0.1.4"
@@ -9968,24 +10576,10 @@
     signal-exit "^3.0.2"
     typedarray-to-buffer "^3.1.5"
 
-write@1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3"
-  integrity sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==
-  dependencies:
-    mkdirp "^0.5.1"
-
-ws@^7.1.2:
-  version "7.2.1"
-  resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.1.tgz#03ed52423cd744084b2cf42ed197c8b65a936b8e"
-  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.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"
@@ -10001,7 +10595,7 @@
 
 xdg-basedir@^4.0.0:
   version "4.0.0"
-  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"
+  resolved "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz"
   integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==
 
 xmlbuilder@8.2.2:
@@ -10029,18 +10623,15 @@
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
   integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
 
-yallist@^3.0.0, yallist@^3.0.2:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.3.tgz#b4b049e314be545e3ce802236d6cd22cd91c3de9"
-  integrity sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==
+yallist@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz"
+  integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
 
-yargs-parser@^18.1.3:
-  version "18.1.3"
-  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
-  integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
-  dependencies:
-    camelcase "^5.0.0"
-    decamelize "^1.2.0"
+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==
 
 yauzl@^2.10.0:
   version "2.10.0"
@@ -10073,26 +10664,30 @@
     text-table "^0.2.0"
     untildify "^2.0.0"
 
-yeoman-environment@^2.0.5:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/yeoman-environment/-/yeoman-environment-2.4.0.tgz#4829445dc1306b02d9f5f7027cd224bf77a8224d"
-  integrity sha512-SsvoL0RNAFIX69eFxkUhwKUN2hG1UwUjxrcP+T2ytwdhqC/kHdnFOH2SXdtSN1Ju4aO4xuimmzfRoheYY88RuA==
+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"
@@ -10125,6 +10720,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"